Skip to content

Add support for the Decorator API in the PDF navigator - #861

Draft
mickael-menu wants to merge 4 commits into
developfrom
feature-pdf-decorator
Draft

Add support for the Decorator API in the PDF navigator#861
mickael-menu wants to merge 4 commits into
developfrom
feature-pdf-decorator

Conversation

@mickael-menu

@mickael-menu mickael-menu commented Jul 16, 2026

Copy link
Copy Markdown
Member

PDFNavigatorViewController now implements DecorableNavigator (proposal 008), so features like annotations and search highlighting can render highlights, underlines and custom UI over PDF publications.

Rendering

Decorations are rendered with PDFPageOverlayViewProvider overlay views (iOS 16+), never by mutating the shared cached PDFDocument. The rationale is recorded in docs/adr/0001-pdf-decoration-rendering.md. On iOS 15, supports(decorationStyle:) returns false and apply() is a no-op that logs a warning.

Overlay views live in page space: PDFKit applies zoom/rotation itself, so decorations stay glued to the text. Z-order is deterministic: groups stack in the order they were first applied, then array order within a group.

Geometry resolution

Each decoration locator resolves to a page (via the existing PDFPageNumberResolver), then to line boxes through a priority chain:

  1. Explicit PDF fragment identifiers in locations.fragmentshighlight=lt,rt,top,btm (one per line of text) or viewrect=x,y,w,h — rendered directly with no text search.
  2. A text search of text.highlight scoped to the page, with normalization (soft hyphens, collapsed whitespace, ligature folding) that maps matches back to original string offsets before building the PDFSelection. text.before/after disambiguate repeated matches.
  3. The whole page, only when the locator deliberately has no text and no rect fragments.

An unresolvable locator (e.g. scanned PDF without a text layer) renders nothing and logs a warning. A failed search never degrades into a full-page highlight.

Selection locators are enriched symmetrically: selectionDidChange now emits one highlight= fragment per selected line plus text.before/after context, so highlights created from a selection round-trip without any text search.

Templates

New public PDFDecorationTemplate (registered via Configuration.decorationTemplates), mirroring EPUB's decorationTemplates:

  • Layout (boxes/bounds) selects the rect set, Width (wrap/bounds/page) adjusts horizontal extent, per the spec.
  • Renderer.view vends a UIView per rect (built-in highlight/underline use this; solid-color views stay sharp at any zoom). Renderer.draw is the CoreGraphics escape hatch, rasterized with a zoom-following contentsScale capped at 4x.
  • Template views are force-set non-interactive, so decorations never intercept text selection or tap handling. Activation is the navigator's own hit-test: the topmost observed decoration consumes the tap and fires onActivated with its bounding rect in navigator coordinates.

@mickael-menu
mickael-menu force-pushed the feature-pdf-decorator branch from 4d2f2ba to 9e5b0dc Compare July 17, 2026 09:08
@mickael-menu
mickael-menu changed the base branch from swift6 to develop August 5, 2026 14:27
@@ -0,0 +1,30 @@
# 1. Render PDF decorations with overlay views, requiring iOS 16

@stevenzeck stevenzeck Aug 10, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this file need to be in the code base?

let decoration = diffable.decoration
guard
isCurrentResource(href: decoration.locator.href),
self.pageIndex(for: decoration.locator, in: document) == pageIndex

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This feels inefficient, iterating through each decoration on each page change. It would be better to sort them in a dictionary when the PDF is first opened.

/// Maximum rasterization scale for `.draw` templates. The backing-store
/// memory grows quadratically with the scale and PDFKit allows deep zoom,
/// so drawings get blurry past this cap.
private static let maxRasterizationScale: CGFloat = 4

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could this be exposed via configuration so developers can judge the tradeoffs?

case let .update(decoration):
invalidateResolvedDecorations(for: decoration.locator, document: document)
// The update may have moved the decoration to another page.
if let old = source.first(where: { $0.decoration.id == decoration.id }) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These two source.first calls have the same problem with efficiency. Better to build and use a dictionary for this too.

Comment on lines +654 to +672
for change in hrefChanges {
switch change {
case let .add(decoration):
invalidateResolvedDecorations(for: decoration.locator, document: document)
case let .remove(id):
if let old = source.first(where: { $0.decoration.id == id }) {
invalidateResolvedDecorations(for: old.decoration.locator, document: document)
} else {
resolvedDecorationsCache.removeAll()
}
case let .update(decoration):
invalidateResolvedDecorations(for: decoration.locator, document: document)
// The update may have moved the decoration to another page.
if let old = source.first(where: { $0.decoration.id == decoration.id }) {
invalidateResolvedDecorations(for: old.decoration.locator, document: document)
}
}
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

resolvedDecorationsCache only holds the few pages currently on screen, correct? Calling invalidateResolvedDecorations for every change during bulk runs the locator for pages that aren't in the cache. Couldn't we just call resolvedDecorationsCache.removeAll() when clearing or adding a whole group?

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants