@@ -180,35 +180,103 @@ class EpisodeDetailVC: UIViewController {
180180 completion: { [ weak self] _ in self ? . updateDownloadButton ( progress: nil ) } )
181181 }
182182
183+ // Show notes are HTML with no literal newlines — the paragraph structure
184+ // lives entirely in the <p>/<br>/<li> tags. Dropping every tag therefore
185+ // collapsed the whole description into one run-on block, so block-level
186+ // tags are turned into line breaks before the rest are discarded.
183187 private static func stripHTML( _ s: String ) -> String {
184- // Remove tags
185188 var out = " "
189+ var tag = " "
186190 var inTag = false
187191 for c in s {
188- if c == " < " { inTag = true }
189- else if c == " > " { inTag = false }
190- else if !inTag { out. append ( c) }
192+ if c == " < " {
193+ inTag = true
194+ tag = " "
195+ } else if c == " > " && inTag {
196+ inTag = false
197+ out += breakFor ( tag: tag)
198+ } else if inTag {
199+ tag. append ( c)
200+ } else {
201+ out. append ( c)
202+ }
203+ }
204+ return collapse ( decodeEntities ( out) )
205+ }
206+
207+ // "" for inline tags (<a>, <em>, <strong>…), a line break for block ones.
208+ private static func breakFor( tag: String ) -> String {
209+ var t = tag. lowercased ( ) . trimmingCharacters ( in: . whitespacesAndNewlines)
210+ let closing = t. hasPrefix ( " / " )
211+ if closing { t. removeFirst ( ) }
212+ // Stop at the first attribute or at a self-closing slash: "br /" -> "br"
213+ let name = t. components ( separatedBy: CharacterSet ( charactersIn: " \t \n \r / " ) ) . first ?? " "
214+ switch name {
215+ case " br " :
216+ return " \n "
217+ case " li " :
218+ return closing ? " " : " \n \u{2022} "
219+ case " p " , " div " , " blockquote " , " pre " , " ul " , " ol " , " table " , " tr " ,
220+ " h1 " , " h2 " , " h3 " , " h4 " , " h5 " , " h6 " :
221+ return " \n \n "
222+ default :
223+ return " "
224+ }
225+ }
226+
227+ private static func decodeEntities( _ s: String ) -> String {
228+ var out = decodeNumericEntities ( s)
229+ // & must come last, or "&lt;" would wrongly end up as "<"
230+ let named = [ ( " < " , " < " ) , ( " > " , " > " ) , ( " " " , " \" " ) ,
231+ ( " ' " , " ' " ) , ( " " , " " ) ,
232+ ( " … " , " \u{2026} " ) , ( " — " , " \u{2014} " ) ,
233+ ( " – " , " \u{2013} " ) ,
234+ ( " ’ " , " \u{2019} " ) , ( " ‘ " , " \u{2018} " ) ,
235+ ( " ” " , " \u{201D} " ) , ( " “ " , " \u{201C} " ) ,
236+ ( " & " , " & " ) ]
237+ for (entity, replacement) in named {
238+ out = out. replacingOccurrences ( of: entity, with: replacement)
191239 }
192- // Decode common HTML entities
193- out = out. replacingOccurrences ( of: " & " , with: " & " )
194- out = out. replacingOccurrences ( of: " < " , with: " < " )
195- out = out. replacingOccurrences ( of: " > " , with: " > " )
196- out = out. replacingOccurrences ( of: " " " , with: " \" " )
197- out = out. replacingOccurrences ( of: " ' " , with: " ' " )
198- out = out. replacingOccurrences ( of: " " , with: " " )
199- // Collapse runs of whitespace/newlines left by removed block tags
200- var result = " "
201- var prevNewline = false
202- for c in out {
203- if c == " \n " || c == " \r " {
204- if !prevNewline { result. append ( " \n " ) }
205- prevNewline = true
240+ return out
241+ }
242+
243+ // Feeds lean heavily on ’ / ’ for typographic punctuation —
244+ // undecoded these showed up as literal noise mid-sentence.
245+ private static func decodeNumericEntities( _ s: String ) -> String {
246+ let parts = s. components ( separatedBy: " &# " )
247+ guard parts. count > 1 else { return s }
248+ var out = parts [ 0 ]
249+ for part in parts. dropFirst ( ) {
250+ guard let semi = part. firstIndex ( of: " ; " ) else { out += " &# " + part; continue }
251+ var body = String ( part [ part. startIndex..< semi] )
252+ let rest = String ( part [ part. index ( after: semi) ... ] )
253+ let value : UInt32 ?
254+ if body. hasPrefix ( " x " ) || body. hasPrefix ( " X " ) {
255+ body. removeFirst ( )
256+ value = UInt32 ( body, radix: 16 )
257+ } else {
258+ value = UInt32 ( body, radix: 10 )
259+ }
260+ if let v = value, let scalar = UnicodeScalar ( v) {
261+ out += String ( Character ( scalar) ) + rest
206262 } else {
207- prevNewline = false
208- result. append ( c)
263+ out += " &# " + part
209264 }
210265 }
211- return result. trimmingCharacters ( in: . whitespacesAndNewlines)
266+ return out
267+ }
268+
269+ // Trim each line and allow at most one blank line between paragraphs.
270+ private static func collapse( _ s: String ) -> String {
271+ let normalized = s. replacingOccurrences ( of: " \r \n " , with: " \n " )
272+ . replacingOccurrences ( of: " \r " , with: " \n " )
273+ var lines : [ String ] = [ ]
274+ for raw in normalized. components ( separatedBy: " \n " ) {
275+ let line = raw. trimmingCharacters ( in: . whitespaces)
276+ if line. isEmpty && ( lines. last? . isEmpty ?? true ) { continue }
277+ lines. append ( line)
278+ }
279+ return lines. joined ( separator: " \n " ) . trimmingCharacters ( in: . whitespacesAndNewlines)
212280 }
213281
214282 @objc private func playTapped( ) {
0 commit comments