Skip to content

Address issue #504: clickable internal anchor links in exported PDF#516

Open
schuyler wants to merge 7 commits into
mainfrom
claude/3000-0-8-milestone-8v9hb8
Open

Address issue #504: clickable internal anchor links in exported PDF#516
schuyler wants to merge 7 commits into
mainfrom
claude/3000-0-8-milestone-8v9hb8

Conversation

@schuyler

@schuyler schuyler commented Jul 6, 2026

Copy link
Copy Markdown
Owner

Summary

Exported PDFs previously lost every intra-document anchor link: printing the WebView through NSPrintOperation does not carry <a href="#slug"> link annotations or id destinations into the PDF, so TOC entries were inert even when their fragment exactly matched a heading id (they worked fine in the preview and in exported HTML). This is the upstream-MacDown limitation described in the issue.

This PR fixes it with PDFKit post-processing of the written export:

  • New MPPDFAnchorInjector (MacDown/Code/Document/MPPDFAnchorInjector.{h,m}) — a pure, headlessly-testable engine. It locates headings and TOC links in the rendered PDF by native text search (findString: / PDFSelection) and injects .link PDFActionGoTo annotations. Geometry comes from the PDF itself, not from on-screen DOM rects, so the repo's @media print reflow (code blocks → pre-wrap, tables → table-layout: fixed, page-break-inside: avoid, a different body width than screen) can't misplace links.
  • MPDocument reads a layout-independent link/heading model (text + slug only) from the WebView DOM via the existing evaluateScript: bridge, then post-processes the exported file in the existing document:didPrint:context: completion callback — gated on a stashed export URL so ordinary ⌘P printing is completely unaffected. Write-back is atomic (temp sibling + replaceItemAtURL:) and best-effort: any failure leaves the valid, un-annotated PDF and never surfaces an error.
  • PDFKit.framework linked into the app and test targets.

Related Issue

Related to #504

Judgment Calls

  • Internal links only. External http(s):// links are also dropped by the print pipeline, but they use a different destination model (a URL action, no destination correlation) and are deliberately out of scope. Notably this is not because they're harder — they're actually simpler — but to keep the review surface focused on the issue's subject. See open question below.
  • Slug collisions → first match. ## C and ## C++ both produce id="c"; a PDF named destination is a single point, so a link resolves to the first matching heading, mirroring browser getElementById / fragment navigation.
  • Geometry via PDF text search, not DOM rects. Forced by the @media print reflow above; this also removes any coordinate-transform/pagination arithmetic.
  • Disambiguation is best-effort. A heading's text appears both in the TOC and as the body heading. Sources/destinations are aligned in document order (the generated TOC precedes the body); among a heading's body occurrences, the first one rendered taller than its TOC entry is promoted (this steps over same-size body prose that repeats the heading text), with a document-order fallback when no occurrence is taller so that same-size headings still resolve.

Testing

8 headless XCTest cases (MacDownTests/MPPDFAnchorInjectorTests.m) run the pure engine against in-test-generated fixture PDFs (real selectable text drawn via CGPDFContext, so findString: can locate it): happy path (correct page + destination point), first-match on slug collision, no-match/inert safety (no crash, valid links unperturbed), empty/no-op, same-size-heading fallback, height-promotes-heading over preceding same-text prose, multiple links to one slug sharing a destination, and the bodyGroup-empty skip.

Limitations & notes for the maintainer

  • Real end-to-end clickability is a manual-QA item, disclosed honestly. Headless CI cannot lay out or paginate a live WebView, so the automated suite proves the injection engine's logic (against real fixture PDFs) and crash-freedom — but not that links click correctly in an actually-exported PDF. Please spot-check: a TOC document jumps to the right headings; a #fragment with no target is inert and doesn't crash; a document with colliding slugs jumps to the first; a heading near a page break lands on the correct page.
  • Named residual (default themes): the shipped default theme GitHub2 renders h5/h6 at body size, so height can't distinguish those headings from body text — they resolve via the document-order fallback, which is correct unless the exact heading text is repeated by body prose appearing before the heading (then the link may land on that prose; on-topic, never a crash).
  • The existing crash-free stub testExportPdfUsesDeferralMechanism (MacDownTests/MPRenderDeferralTests.m) was left as-is rather than extended — the live export path can't be driven headlessly beyond the crash-free assertion it already makes.

Open questions

  1. Also restore external links in the same pass (low marginal cost), or keep it strictly internal?
  2. Is best-effort, page-correct placement acceptable, or is occurrence-accurate mapping required (would need a windowed UI test or manual sign-off)?
  3. Confirm the first-match slug-collision rule is desired.
  4. Interest in a follow-up windowed UI test to close the clickability-verification gap headless CI can't cover?

Generated by Claude Code

Exported PDFs previously lost all intra-document anchor links: printing
the WebView through NSPrintOperation does not carry <a href="#slug"> link
annotations or id destinations into the PDF, so TOC entries were inert
even when their fragment exactly matched a heading id.

Fix via PDFKit post-processing of the written export:
- New MPPDFAnchorInjector: a pure, headlessly-testable engine that locates
  headings and TOC links in the rendered PDF by native text search
  (findString:/PDFSelection) and injects .link GoTo annotations. Geometry
  comes from the PDF itself, not on-screen DOM rects, so @media print
  reflow can't misplace links. Slug collisions resolve to first match;
  a taller-than-TOC-entry occurrence is promoted over same-size prose,
  with a document-order fallback so same-size (h5/h6) headings stay
  clickable.
- MPDocument reads a layout-independent link/heading model from the
  WebView DOM (evaluateScript:) and post-processes the exported file in
  the existing document:didPrint:context: callback, gated on a stashed
  export URL so ordinary Cmd-P printing is unaffected. Write-back is
  atomic and best-effort: any failure leaves the valid un-annotated PDF.
- Link PDFKit.framework into the app and test targets.

Tests: 8 headless XCTest cases against in-test-generated fixture PDFs
cover the happy path, first-match collision, no-match/inert safety,
empty no-op, same-size fallback, height-promotes-heading, multiple links
to one slug, and the bodyGroup-empty skip.

Related to #504
@github-actions

github-actions Bot commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Code Coverage Report

Current Coverage: 63.53%

Coverage Details (Summary)
Name                                                                                                                                   Coverage             
-------------------------------------------------------------------------------------------------------------------------------------- -------------------- 
MASPreferences.bundle                                                                                                                  0.00% (0/0)          
MacDown 3000.app                                                                                                                       63.82% (9594/15032)  
    /Users/runner/work/macdown3000/macdown3000/MacDown/Code/Extension/NSColor+HTML.m                                                   94.05% (332/353)     
        +[NSColor(HTML) colorWithHexString:]                                                                                           94.12% (16/17)       
        +[NSColor(HTML) colorWithHTMLName:]                                                                                            89.13% (164/184)     
        __35+[NSColor(HTML) colorWithHTMLName:]_block_invoke                                                                           100.00% (152/152)    
    /Users/runner/work/macdown3000/macdown3000/MacDown/Code/Preferences/MPHtmlPreferencesViewController.m                              0.00% (0/113)        
        MPPrismDefaultThemeName                                                                                                        0.00% (0/2)          
        -[MPHtmlPreferencesViewController viewIdentifier]                                                                              0.00% (0/3)          
        -[MPHtmlPreferencesViewController toolbarItemImage]                                                                            0.00% (0/3)          
        -[MPHtmlPreferencesViewController toolbarItemLabel]                                                                            0.00% (0/2)          
        -[MPHtmlPreferencesViewController viewWillAppear]                                                                              0.00% (0/4)          
        -[MPHtmlPreferencesViewController changeStylesheet:]                                                                           0.00% (0/7)          
        -[MPHtmlPreferencesViewController changeHighlightingTheme:]                                                                    0.00% (0/7)          
        -[MPHtmlPreferencesViewController invokeStylesheetFunction:]                                                                   0.00% (0/23)         
        -[MPHtmlPreferencesViewController invokeHighlightingThemeFunction:]                                                            0.00% (0/33)         
        -[MPHtmlPreferencesViewController loadStylesheets]                                                                             0.00% (0/14)         
        -[MPHtmlPreferencesViewController loadHighlightingThemes]                                                                      0.00% (0/15)         
    /Users/runner/work/macdown3000/macdown3000/MacDown/Code/Utility/FileURLInlining.m                                                  26.60% (25/94)       
        +[FileURLInlining inlineFromIterable:]                                                                                         0.00% (0/11)         
        -[FileURLInlining initWithURL:]                                                                                                0.00% (0/6)          
        -[FileURLInlining init]                                                                                                        100.00% (3/3)        
        -[FileURLInlining inlineContent]                                                                                               0.00% (0/9)          
        -[FileURLInlining imageContent]                                                                                                0.00% (0/12)         
        +[FileURLInlining mimeTypeForFilePath:]                                                                                        100.00% (22/22)      
        -[FileURLInlining clippingContent]                                                                                             0.00% (0/4)          
        +[FileURLInlining isTextClippingAtPath:]                                                                                       0.00% (0/12)         
        +[FileURLInlining textContentFromClipping:]                                                                                    0.00% (0/15)         
    /Users/runner/work/macdown3000/macdown3000/Dependency/peg-markdown-highlight/HGMarkdownHighlightingStyle.m                         89.23% (58/65)       
        +[HGMarkdownHighlightingStyle colorFromARGBColor:]                                                                             100.00% (6/6)        
        -[HGMarkdownHighlightingStyle initWithType:attributesToAdd:toRemove:fontTraitsToAdd:]                                          88.89% (8/9)         
        -[HGMarkdownHighlightingStyle initWithStyleAttributes:baseFont:]                                                               88.00% (44/50)       
    /Users/runner/work/macdown3000/macdown3000/MacDown/Code/Utility/MPResourceWatcherSet.m                                             93.75% (90/96)       
        -[MPResourceWatcherSet init]                                                                                                   100.00% (6/6)        
        -[MPResourceWatcherSet dealloc]                                                                                                0.00% (0/3)          
        -[MPResourceWatcherSet watchedPaths]                                                                                           100.00% (3/3)        
        -[MPResourceWatcherSet updateWatchedPaths:]                                                                                    100.00% (16/16)      
        -[MPResourceWatcherSet addWatcherForPath:]                                                                                     100.00% (34/34)      
        __42-[MPResourceWatcherSet addWatcherForPath:]_block_invoke                                                                    100.00% (6/6)        
        __42-[MPResourceWatcherSet addWatcherForPath:]_block_invoke.5                                                                  93.33% (14/15)       
        __42-[MPResourceWatcherSet addWatcherForPath:]_block_invoke_2                                                                  75.00% (6/8)         
        -[MPResourceWatcherSet stopAll]                                                                                                100.00% (5/5)        
    /Users/runner/work/macdown3000/macdown3000/MacDown/Code/Document/MPPDFAnchorInjector.m                                             82.07% (151/184)     
        +[MPPDFAnchorLink linkWithText:slug:]                                                                                          100.00% (6/6)        
        +[MPPDFAnchorHeading headingWithSlug:text:]                                                                                    100.00% (6/6)        
        MPPDFAnchorStringIsBlank                                                                                                       100.00% (8/8)        
        MPPDFAnchorSelectionHeight                                                                                                     60.00% (9/15)        
        +[MPPDFAnchorInjector injectLinksIntoDocument:links:headings:]                                                                 81.88% (122/149)     

... (3520 more lines truncated)

📊 **Full coverage report available in workflow artifacts**

claude added 6 commits July 6, 2026 06:05
Test fixture (CI fix): the in-test fixture PDFs drew text via a raw
CGPDFContext, which produced no ToUnicode mapping and so was not
findString:-searchable on the CI runners — the injector found nothing
and every behavioral assertion saw zero annotations. Regenerate each
fixture page through an offscreen NSView's -dataWithPDFInsideRect: (the
AppKit text-layout path that yields genuinely searchable text, matching
the real export), merge pages via PDFKit, and force a synchronous text
parse before searching. Expected rects are now measured from the finished
document so geometry assertions stay self-consistent.

Export post-processing hardening (self-review):
- Guard against overlapping exports: exportPdf: is a no-op while a prior
  export is still in flight, so the single-slot URL stash can't be
  clobbered (which would have left both exports un-annotated).
- Log and clean up on the post-processing failure paths (reopen/write/
  replace), passing a real NSError and removing the temp file so no stray
  <uuid>.pdf is left beside the user's export. Success and Cmd-P paths
  unchanged.
- Log (instead of silently swallowing) the injector's per-link and search
  exceptions; collapse a redundant source-height lookup.

Tests: add coverage for a blank link text being skipped and for a second
same-text link with no second PDF occurrence being skipped. Remove the
stale red-state/no-op-stub comments now that the engine is implemented.

Related to #504
The fixture drew text with the system font (San Francisco). When AppKit
embeds that private UI font via -dataWithPDFInsideRect: in headless CI it
does not emit a ToUnicode CMap, so the glyphs render but are not
text-extractable and -[PDFDocument findString:] finds nothing — the
injector saw an empty document and every annotation assertion read zero.

Draw with Helvetica (a standard PDF base-14 font, always present on
macOS), which embeds a correct ToUnicode mapping, so the fixture text is
searchable and the tests exercise the real engine.

Related to #504
…archable

The fixture built each page as a separate one-page PDF via
-[NSView dataWithPDFInsideRect:] and merged them with -insertPage:. That
cross-document copy dropped the searchable text on every merged page
(page 0 was searchable, pages 1+ were not), so the injector could not
locate headings on later pages and added no annotations.

Generate the whole multi-page fixture in a single CGPDFContext instead
(one document, no insertPage: merge), still drawing with Helvetica. Every
page's glyphs live in the one document PDFKit loads, so findString: locates
text on all pages and the tests exercise the real engine end to end.

Related to #504
…-page text

Raw CGPDFContext text was findString:-searchable only on the first page in
CI (a Core Graphics multi-page font/ToUnicode embedding pitfall), so the
injector could not locate headings on later pages and added no annotations.
Two CGPDFContext variants (per-page NSView merge, then single context)
reproduced the identical page-0-only-searchable signature.

Generate the multi-page fixture by printing a tall, auto-paginated NSView
through NSPrintOperation (NSPrintSaveJob) instead — the same AppKit
printing path MacDown's real PDF export uses, which embeds correct text
encoding on every page. Each page is one paper-height slice of the view,
so a pageIndex-k item lands on printed page k; the produced PDF is loaded
and its text located exactly as the engine does.

Related to #504
Switching the fixture to NSPrintOperation made multi-page text searchable,
but every fixture came out with one extra page: the tall view was sized as
792pt * pageCount, while AppKit auto-paginates against the printer's
imageable height (nominal paper minus the "Save as PDF" printer's fixed
unprintable margins, which zeroing the user margins does not remove). The
view was therefore an exact multiple of 792 but sliced against a slightly
shorter height, yielding pageCount+1 pages and shifting items off their
intended pages.

Size the view and place items by printInfo.imageablePageBounds.size.height
(the height AppKit actually slices against) so the view is exactly
pageCount imageable-slices tall and produces exactly pageCount pages, with
each pageIndex-k item on printed page k.

Related to #504
The injector added link annotations to, and built PDFDestinations from,
the PDFPage returned by PDFSelection.pages.firstObject. That is a transient
page wrapper, not the object -[PDFDocument pageAtIndex:] vends, so the
annotations were invisible when the page was re-queried and — critically —
were not serialized by -[PDFDocument writeToURL:] in the real export path
(MPDocument postProcessExportedPDFAtURL:). The exported PDF would have had
no clickable links even though injection reported success.

Re-home both the source page (for addAnnotation:) and the destination page
(for PDFDestination) onto [document pageAtIndex:[document indexForPage:...]]
before mutating, so annotations land on — and persist through writeToURL:
on — the canonical page. Geometry, counts, ordering, and disambiguation are
unchanged (bounds/heights are computed before re-homing).

Also nudge the lone body heading in one fixture off the page-top edge so it
renders searchably within its page.

Related to #504

schuyler commented Jul 6, 2026

Copy link
Copy Markdown
Owner Author

Status: blocked on CI — needs a macOS/Xcode runtime to finish

Honest state of this PR: the new MPPDFAnchorInjectorTests are red and I could not get them green from this (Linux, no-Xcode) environment. The product code compiles and the rest of the suite (1071 pre-existing tests) passes; the 16 failures are all in the new headless anchor-injection tests. I'm handing off with a precise diagnosis rather than guessing further — the remaining issues genuinely need a local macOS run to resolve.

What got solved

Generating a findString:-searchable multi-page PDF fixture headlessly was the hard part. Raw CGPDFContext (two variants) only embedded searchable text on page 0. Switching the fixture to print a tall auto-paginated NSView through NSPrintOperation (the same AppKit path the real export uses) fixed searchability on every page, and reading imageablePageBounds.size.height fixed an off-by-one page count. Those are settled.

What remains (2 independent, real issues + 1 flake)

  1. Annotation persistence — the important one. +injectLinksIntoDocument: reports the correct number added, but re-fetching the page via [document pageAtIndex:N].annotations returns 0 in every affected test. The engine builds annotations against the PDFPage vended by PDFSelection.pages.firstObject, which is not the object pageAtIndex: vends. I tried re-homing onto [document pageAtIndex:[document indexForPage:page]] before addAnnotation:it changed nothing (indexForPage: almost certainly returns NSNotFound for a selection-vended page, so the guard no-ops). This needs runtime inspection to settle which of two things is true:

    • the engine really doesn't persist annotations (they'd also be lost by writeToURL: in MPDocument.postProcessExportedPDFAtURL:, i.e. a genuine feature bug — exported PDFs would have no links); or
    • writeToURL: does persist them and only the test's pageAtIndex: + .annotations re-query is the wrong way to verify, i.e. the feature works and the tests need to assert differently.

    I can't disambiguate these without logging what indexForPage: returns / inspecting whether writeToURL: output contains the /Annots — both require a macOS/Xcode session.

  2. A real disambiguation count bug. testSecondLinkWithSharedTextButNoSecondOccurrenceIsSkipped: two links share linkText "Repeat" with a single drawn "Repeat" occurrence; the engine produces 2 annotations where the k >= tocSelections.count guard should skip the second (added==2 vs expected 1). This is orthogonal to persistence and looks like a genuine occurrence-matching bug (or findString:@"Repeat" returning more matches than expected) — needs the live textToMatches/linkTextCounter state to pin down.

  3. One fixture flake. testUnmatched…: the body heading "Real" occasionally isn't located by findString: on its page; nudging its position didn't reliably fix it.

Why I stopped

Six CI-fix cycles, and the last one made no measurable progress. The remaining problems require observing actual PDFKit behavior (what indexForPage:/addAnnotation:/writeToURL: do across an initWithData: round-trip), which isn't possible in this headless Linux container. Continuing would be blind guessing.

To finish (on macOS)

Run xcodebuild test -only-testing:MacDownTests/MPPDFAnchorInjectorTests with logging/breakpoints to: (a) confirm whether indexForPage: hits NSNotFound and either find the correct canonical-page handle or verify writeToURL: already persists the annotations and adjust the tests' verification instead; (b) fix the "Repeat" double-count; (c) stabilise the "Real" fixture. Once those three are addressed the suite should pass.


Maintainer notes (from self-review; deliberately not addressed here)

  • kMPAnchorModelJS is an inline ObjC string literal; the existing Editor and Preview panes out of sync #436 JS bridge loads its script from a bundled .js resource — worth matching that convention in a follow-up.
  • The PDFKit post-processing (reopen / findString: loop / write-back) runs on the main thread; only the WebView DOM read needs to. It could move to a background queue after the DOM snapshot if large-document responsiveness ever matters.
  • Documented best-effort limits already noted in the PR body: substring over-match for very short heading text; h5/h6 under the default GitHub2 theme render at body size so they resolve via document-order fallback; real clickability is a manual-QA item.

Branch claude/3000-0-8-milestone-8v9hb8 is pushed at the latest commit and builds cleanly; only the new tests fail.


Generated by Claude Code

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