Skip to content

Decode character references in scraped titles on iOS and macOS as Android does - #24

Open
cdeutsch wants to merge 6 commits into
mainfrom
claude/epic-fermi-11b05a
Open

cdeutsch wants to merge 6 commits into
mainfrom
claude/epic-fermi-11b05a

Conversation

@cdeutsch

Copy link
Copy Markdown
Contributor

The Apple scraper deleted decimal character references from a page's <title>, so a title written as It&#8217;s here was stored as Its here. It also left hex references and most named ones (&rsquo;, &hellip;) as literal text. The scraped title is written into the push's envelope and read by every device, so a garbled title stayed for good, and it disagreed with Android, whose Jsoup decodes all of them.

What changes

Title extraction moves from TitleScraperService into PushTitle in Services/PushContent.swift, which the test target already compiles (as PushIcon does), so no project file changes. It now produces the title Jsoup's doc.title() gives for the same page:

  • Decimal and hex references decode in one pass from the left, so &amp;#39; stays the text &#39;. The ; is optional, and 0x80 to 0x9F are read as Windows-1252, as browsers and Jsoup do.
  • HTML 4's 252 names, &apos; and HTML5's uppercase aliases (&AMP;), each to its own character. &ndash;, &mdash; and &copy; used to become - and (c).
  • HTML's 106 legacy names also decode without their ; (&copy 2024), by the longest legacy prefix (&notit; is ¬it;).
  • Whitespace is collapsed over Jsoup's six characters only and trimmed as Java's trim() does, so a thin or ideographic space is kept. Soft hyphens and zero-width spaces are dropped, and a NUL becomes U+FFFD, as in Jsoup.

apps/macos is a hand-maintained fork of apps/ios, and every commit makes the same change in both.

Where Apple still differs from Android

Documented in the code: a numeric reference that names no character (&#0;, a surrogate, past U+10FFFF) stays as written, where Jsoup drops it or makes U+FFFD of it, and HTML5-only names such as &par; are not decoded. No real title should hit either.

PushIcon.decodeEntities keeps its own decoder on purpose: in an attribute, HTML leaves a legacy name before = or a letter as text, so sharing this one would turn ?a=1&copy=2 into ©=2.

Verification

  • PushTitleTests covers decimal, hex, invalid, legacy and mixed references, and whitespace. Every expected string was checked against Jsoup 1.23.2, the version Android uses. A review pass also compared about 23,000 generated titles between the two, and found no differences beyond the documented ones.
  • iOS: xcodebuild test on the iPhone 15 Pro simulator passes.
  • macOS: xcodebuild test with MACOSX_DEPLOYMENT_TARGET=14.0 passes (the plain build fails on the known deployment-target issue, see docs/macos.md).

CI runs no Xcode builds, so these were run locally.

🤖 Generated with Claude Code

cdeutsch and others added 6 commits November 12, 2026 16:09
The Apple scraper deleted every decimal reference in a page's `<title>` and left hex ones as written, so a title written as `It&#8217;s here` was stored as `Its here`. The title is written into the push's envelope and read by every device, so the garbled one stayed for good, and it disagreed with Android, whose Jsoup decodes them.

The title extraction moves from `TitleScraperService` into `PushTitle` in `PushContent.swift`, which the tests already compile, and decodes in one pass from the left: decimal and hex references (the `;` optional, 0x80 to 0x9F read as Windows-1252, as browsers and Jsoup do), and the named ones it already knew, now to their own characters rather than ASCII stand-ins, so `&mdash;` and `&#8212;` give the same title. A reference that names no character is left as text. Decoding now comes before the whitespace is collapsed, as it does in Jsoup.

`PushTitleTests` covers decimal, hex, invalid and mixed cases, with expected strings checked against Jsoup 1.23.2.

This makes the same change in both apps: `apps/macos` is a fork of `apps/ios`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Apple scraper knew ten names, so a title holding `&rsquo;`, `&hellip;` or `&eacute;` was stored with the reference as literal text, while Android's Jsoup decoded it. The title is stored for every device, so the two disagreed for good.

`PushTitle` now knows HTML 4's 252 names and `&apos;`, with `&lang;` and `&rang;` at HTML5's code points as Jsoup has them. That covers what titles use without carrying HTML5's two thousand, nearly all mathematical symbols. It also drops the soft hyphen and the zero-width space from the title, as Jsoup does when it collapses whitespace. Every name was checked against Jsoup 1.23.2.

This makes the same change in both apps: `apps/macos` is a fork of `apps/ios`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`PushTitle` collapsed and trimmed with ICU's `\s` and `.whitespacesAndNewlines`, which take every Unicode space. Jsoup, which Android scrapes with, collapses only space, tab, newline, form feed, carriage return and the no-break space, and trims what is at or below U+0020. Now that references are decoded, a title with `&thinsp;`, the narrow no-break space French writes before `!`, or an ideographic space was stored as a plain space on Apple and as written on Android, and the first device to scrape decided which every device saw.

The title is now collapsed over Jsoup's six and trimmed as Java's `trim()` does, checked against Jsoup 1.23.2.

This makes the same change in both apps: `apps/macos` is a fork of `apps/ios`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… macOS

`PushTitle` required a `;` after every name, on the claim that no page writes one without it. Hand-written titles do: `&copy 2024` and `Fish &amp Chips` were stored as written on Apple, while Android's Jsoup decodes them as HTML says to, and the first device to scrape decided which every device saw. The uppercase aliases (`&AMP;`, `&COPY;`) were missing too.

A name now decodes with its `;` from the table, or else as the longest start of it that is one of HTML's 106 legacy names, the rest left as text (`&notit;` is `¬it;`), which is HTML's rule and Jsoup's. The aliases join the table. Checked against Jsoup 1.23.2.

This makes the same change in both apps: `apps/macos` is a fork of `apps/ios`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An HTML parser reads a NUL in a `<title>` as U+FFFD, and Jsoup on Android does. `PushTitle` kept it, and since the title is now trimmed as Java's `trim()` does, one at either end was dropped instead. The title is stored for every device, so the two clients stored different titles for the same page.

The NUL now becomes U+FFFD before anything else, checked against Jsoup 1.23.2.

This makes the same change in both apps: `apps/macos` is a fork of `apps/ios`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…macOS

The comment on `PushTitle.decodingEntities` listed where Jsoup's answer for an invalid numeric reference differs from keeping it as written, and left out `&#0;`, which Jsoup drops. A reader checking the two clients against each other would take that difference for a bug. Comment only.

This makes the same change in both apps: `apps/macos` is a fork of `apps/ios`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions github-actions Bot added ios The iOS app (apps/ios) macos The macOS app (apps/macos) labels Nov 13, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ios The iOS app (apps/ios) macos The macOS app (apps/macos)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant