diff --git a/MacDown 3000.xcodeproj/project.pbxproj b/MacDown 3000.xcodeproj/project.pbxproj index f8dc9fc6..fca65fa3 100644 --- a/MacDown 3000.xcodeproj/project.pbxproj +++ b/MacDown 3000.xcodeproj/project.pbxproj @@ -154,6 +154,10 @@ URLSECPOL0001BUILDFILE /* MPURLSecurityPolicy.m in Sources */ = {isa = PBXBuildFile; fileRef = URLSECPOLIMPFILEREFID /* MPURLSecurityPolicy.m */; }; URLSECPOL0002BUILDFILE /* MPURLSecurityPolicyTests.m in Sources */ = {isa = PBXBuildFile; fileRef = URLSECPOLTSTFILEREFID /* MPURLSecurityPolicyTests.m */; }; UITEST000004SWIFTSRC /* MacDownUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = UITEST000001SWIFTFILE /* MacDownUITests.swift */; }; + 504AC0000000000000000003 /* MPPDFAnchorInjector.m in Sources */ = {isa = PBXBuildFile; fileRef = 504AC0000000000000000002 /* MPPDFAnchorInjector.m */; }; + 504AC0000000000000000005 /* MPPDFAnchorInjectorTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 504AC0000000000000000004 /* MPPDFAnchorInjectorTests.m */; }; + 504AC0000000000000000007 /* PDFKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 504AC0000000000000000006 /* PDFKit.framework */; }; + 504AC0000000000000000008 /* PDFKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 504AC0000000000000000006 /* PDFKit.framework */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -709,6 +713,10 @@ UITEST000001SWIFTFILE /* MacDownUITests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MacDownUITests.swift; sourceTree = ""; }; UITEST000002INFOPLIST /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; UITEST000003XCTEST /* MacDownUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = MacDownUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 504AC0000000000000000001 /* MPPDFAnchorInjector.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = MPPDFAnchorInjector.h; sourceTree = ""; }; + 504AC0000000000000000002 /* MPPDFAnchorInjector.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = MPPDFAnchorInjector.m; sourceTree = ""; }; + 504AC0000000000000000004 /* MPPDFAnchorInjectorTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = MPPDFAnchorInjectorTests.m; sourceTree = ""; }; + 504AC0000000000000000006 /* PDFKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = PDFKit.framework; path = System/Library/Frameworks/PDFKit.framework; sourceTree = SDKROOT; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -720,6 +728,7 @@ 1FF207631941CF22005B5654 /* WebKit.framework in Frameworks */, 1FA6DE251941CC9E000409FB /* Cocoa.framework in Frameworks */, 770BB49E962A302D0715A6A5 /* libPods-MacDown.a in Frameworks */, + 504AC0000000000000000007 /* PDFKit.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -732,6 +741,7 @@ 1FF1420519A8987600CF8A6A /* JavaScriptCore.framework in Frameworks */, QLBLD00007WEBKITTESTFW /* WebKit.framework in Frameworks */, EBFE3157737058F5A63699C8 /* libPods-MacDownTests.a in Frameworks */, + 504AC0000000000000000008 /* PDFKit.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -867,6 +877,8 @@ 1F0D9D91194AC852008E1856 /* MPDocument.h */, 1F0D9D92194AC852008E1856 /* MPDocument.m */, 1F0D9D83194AC83C008E1856 /* MPDocument.xib */, + 504AC0000000000000000001 /* MPPDFAnchorInjector.h */, + 504AC0000000000000000002 /* MPPDFAnchorInjector.m */, ); name = Document; path = Code/Document; @@ -1005,6 +1017,7 @@ 1F51C9A1194564670015A96F /* libPods-MacDown-PAPreferences.a */, 1FC29F5B1944FC2600D616C7 /* XCTest.framework */, 1FF207621941CF22005B5654 /* WebKit.framework */, + 504AC0000000000000000006 /* PDFKit.framework */, 1FA6DE241941CC9E000409FB /* Cocoa.framework */, 905EF1A8196164CA00FC3CE9 /* Foundation.framework */, 1FA6DE261941CC9E000409FB /* Other Frameworks */, @@ -1119,6 +1132,7 @@ QLTST00002PREF00000M /* MPQuickLookPreferencesTests.m */, QLTST00003PVCT00000M /* MPPreviewViewControllerTests.m */, 5E44CF1BD72E49A99DA6A4C1 /* MPPaneToggleTests.m */, + 504AC0000000000000000004 /* MPPDFAnchorInjectorTests.m */, ); path = MacDownTests; sourceTree = ""; @@ -1768,6 +1782,7 @@ D29776CC6E7EB5B4AA2E0537 /* MPFileWatcher.m in Sources */, BA2B9EC2E95175092A97B41E /* MPResourceWatcherSet.m in Sources */, 1F13B5E1110695033F2B57AE /* MPHTMLResourceURLs.m in Sources */, + 504AC0000000000000000003 /* MPPDFAnchorInjector.m in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1820,6 +1835,7 @@ QLBLD00005PREFTESTSRC /* MPQuickLookPreferencesTests.m in Sources */, QLBLD00006PVCTESTSRC0 /* MPPreviewViewControllerTests.m in Sources */, 1457815B1A710E4C40F07FCA /* MPPaneToggleTests.m in Sources */, + 504AC0000000000000000005 /* MPPDFAnchorInjectorTests.m in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/MacDown/Code/Document/MPDocument.m b/MacDown/Code/Document/MPDocument.m index bdc56477..5f911296 100644 --- a/MacDown/Code/Document/MPDocument.m +++ b/MacDown/Code/Document/MPDocument.m @@ -35,9 +35,46 @@ #import "MPHTMLResourceURLs.h" #import "MPURLSecurityPolicy.h" #import +// Issue #504: PDF export post-processing (clickable internal anchor links). +#import +#import "MPPDFAnchorInjector.h" static NSString * const kMPDefaultAutosaveName = @"Untitled"; +// Issue #504: Reads the anchor-link model from the rendered preview DOM for +// PDF export post-processing. An IIFE evaluated via the same +// evaluateScript: bridge already proven by -updateHeaderLocations +// (Issue #436, see :2908 nearby). Enumerates internal fragment links +// (`a[href^="#"]`) and headings with an id (`h1[id]`..`h6[id]`) in document +// order (querySelectorAll is document-ordered), collapsing whitespace and +// skipping anything with an empty fragment or empty visible text. Returns a +// JS object (read back via JSValue subscripting — never a JSON string): +// {links:[{linkText,targetSlug}], headings:[{slug,headingText}]}. +static NSString * const kMPAnchorModelJS = @"(function() {" + " function normalize(el) {" + " return (el.textContent || '').replace(/\\s+/g, ' ').trim();" + " }" + " var links = [];" + " var anchors = document.querySelectorAll('a[href^=\"#\"]');" + " for (var i = 0; i < anchors.length; i++) {" + " var a = anchors[i];" + " var href = a.getAttribute('href') || '';" + " var slug = href.slice(1);" + " var text = normalize(a);" + " if (!slug || !text) continue;" + " links.push({ linkText: text, targetSlug: slug });" + " }" + " var headings = [];" + " var heads = document.querySelectorAll('h1[id], h2[id], h3[id], h4[id], h5[id], h6[id]');" + " for (var j = 0; j < heads.length; j++) {" + " var h = heads[j];" + " var slug2 = h.getAttribute('id') || '';" + " var text2 = normalize(h);" + " if (!slug2 || !text2) continue;" + " headings.push({ slug: slug2, headingText: text2 });" + " }" + " return { links: links, headings: headings };" + "})();"; NS_INLINE NSString *MPEditorPreferenceKeyWithValueKey(NSString *key) { @@ -234,6 +271,13 @@ typedef NS_ENUM(NSInteger, MPReferenceKind) { @property CGFloat lastNonCollapsedRatio; @property BOOL manualRender; @property BOOL printing; +// Issue #504: Single-slot stash so the shared print-completion callback +// (-document:didPrint:context:) knows whether this completion was a +// save-to-PDF export (-exportPdf:) and, if so, which file to post-process +// with clickable internal anchor links. Mirrors the `printing` BOOL +// stash/clear convention above: set right before printing is dispatched, +// always cleared once the callback fires. +@property (strong) NSURL *pdfExportURL; @property BOOL isPreviewReady; @property (strong) NSURL *currentBaseUrl; @property (copy) NSString *currentStyleName; @@ -312,6 +356,10 @@ - (void)handleSyncScrollingEnabled; - (void)handleSyncScrollingDisabled; // Commit 8 (gap 9): MathJax generation counter accessor (used by tests via category) - (NSUInteger)mathJaxRenderGeneration; +// Issue #504: PDF export post-processing (clickable internal anchor links). +- (BOOL)readAnchorLinks:(NSArray **)outLinks + headings:(NSArray **)outHeadings; +- (void)postProcessExportedPDFAtURL:(NSURL *)url; @end @@ -2124,6 +2172,14 @@ - (IBAction)exportHtml:(id)sender - (IBAction)exportPdf:(id)sender { + // Issue #504: The single-slot stash can only hold one in-flight export's + // destination URL. If it is already set, a previous export's save panel + // or print/write hasn't completed yet (document:didPrint:context: clears + // it in @finally); starting a second export here would clobber the stash + // and corrupt both exports' post-processing. Make it a safe no-op. + if (self.pdfExportURL) + return; + NSSavePanel *panel = [NSSavePanel savePanel]; panel.allowedFileTypes = @[@"pdf"]; if (self.presumedFileName) @@ -2138,6 +2194,11 @@ - (IBAction)exportPdf:(id)sender if (result != NSFileHandlingPanelOKButton) return; + // Issue #504: Stash the destination URL so -document:didPrint:context: + // knows this print completion is a save-to-PDF export and which file + // to post-process with clickable internal anchor links. + self.pdfExportURL = panel.URL; + // Issue #16: printDocumentWithSettings: already handles render deferral NSDictionary *settings = @{ NSPrintJobDisposition: NSPrintSaveJob, @@ -2148,6 +2209,133 @@ - (IBAction)exportPdf:(id)sender }]; } +// Issue #504: Read the anchor-link model (links needing destinations, and the +// headings that can serve as destinations) from the live preview DOM, using +// the same evaluateScript: bridge already proven by -updateHeaderLocations +// (Issue #436) — not -stringByEvaluatingJavaScriptFromString:, which swallows +// JS errors. Main-thread only; never dispatches. Returns NO (leaving the +// exported PDF un-annotated) on any failure to read or parse the model. +- (BOOL)readAnchorLinks:(NSArray **)outLinks + headings:(NSArray **)outHeadings +{ + JSContext *ctx = self.preview.mainFrame.javaScriptContext; + if (!ctx) + return NO; + + JSValue *result = [ctx evaluateScript:kMPAnchorModelJS]; + if (![result isObject]) + return NO; + + NSArray *linkDicts = [result[@"links"] toArray]; + NSArray *headDicts = [result[@"headings"] toArray]; + if (![linkDicts isKindOfClass:[NSArray class]] || + ![headDicts isKindOfClass:[NSArray class]]) + return NO; + + NSMutableArray *links = [NSMutableArray array]; + for (NSDictionary *dict in linkDicts) + { + if (![dict isKindOfClass:[NSDictionary class]]) + continue; + NSString *linkText = dict[@"linkText"]; + NSString *targetSlug = dict[@"targetSlug"]; + if (![linkText isKindOfClass:[NSString class]] || linkText.length == 0) + continue; + if (![targetSlug isKindOfClass:[NSString class]] || targetSlug.length == 0) + continue; + [links addObject:[MPPDFAnchorLink linkWithText:linkText slug:targetSlug]]; + } + + NSMutableArray *headings = [NSMutableArray array]; + for (NSDictionary *dict in headDicts) + { + if (![dict isKindOfClass:[NSDictionary class]]) + continue; + NSString *slug = dict[@"slug"]; + NSString *headingText = dict[@"headingText"]; + if (![slug isKindOfClass:[NSString class]] || slug.length == 0) + continue; + if (![headingText isKindOfClass:[NSString class]] || headingText.length == 0) + continue; + [headings addObject:[MPPDFAnchorHeading headingWithSlug:slug text:headingText]]; + } + + if (outLinks) + *outLinks = links; + if (outHeadings) + *outHeadings = headings; + return YES; +} + +// Issue #504: Best-effort post-processing of a just-exported PDF: read the +// anchor-link model from the (still-loaded) preview DOM and inject clickable +// internal-link annotations into the written file. The export has already +// succeeded by the time this runs, so any failure here is silently logged — +// never surfaced to the user — and leaves the exported PDF exactly as +// written. Write-back is atomic: annotate a sibling temp copy, then swap it +// in, so a crash or error mid-write can never corrupt the exported file. +- (void)postProcessExportedPDFAtURL:(NSURL *)url +{ + @try { + NSArray *links = nil; + NSArray *headings = nil; + if (![self readAnchorLinks:&links headings:&headings]) + return; + if (links.count == 0) + return; + + PDFDocument *pdf = [[PDFDocument alloc] initWithURL:url]; + if (!pdf) + { + // Issue #504: Reopening the just-exported PDF failed; leave the + // valid, un-annotated export in place. + NSLog(@"[Issue #504] PDF anchor post-processing failed: " + @"could not reopen exported PDF at %@", url); + return; + } + + NSUInteger added = [MPPDFAnchorInjector injectLinksIntoDocument:pdf + links:links + headings:headings]; + if (added == 0) + return; + + NSString *tmpName = + [[NSUUID UUID].UUIDString stringByAppendingPathExtension:@"pdf"]; + NSURL *tmpURL = [url.URLByDeletingLastPathComponent + URLByAppendingPathComponent:tmpName]; + if (![pdf writeToURL:tmpURL]) + { + // Issue #504: Failed to write the annotated copy; clean up any + // partial temp file and leave the original export untouched. + NSLog(@"[Issue #504] PDF anchor post-processing failed: " + @"could not write annotated copy to %@", tmpURL); + [[NSFileManager defaultManager] removeItemAtURL:tmpURL error:NULL]; + return; + } + + NSError *replaceError = nil; + BOOL replaced = [[NSFileManager defaultManager] replaceItemAtURL:url + withItemAtURL:tmpURL + backupItemName:nil + options:0 + resultingItemURL:NULL + error:&replaceError]; + if (!replaced) + { + // Issue #504: The atomic swap failed; the original export is + // untouched, but the temp copy is now an orphaned stray file. + NSLog(@"[Issue #504] PDF anchor post-processing failed: " + @"could not replace %@ with annotated copy: %@", + url, replaceError); + [[NSFileManager defaultManager] removeItemAtURL:tmpURL error:NULL]; + } + } + @catch (NSException *ex) { + NSLog(@"[Issue #504] PDF anchor post-processing failed: %@", ex); + } +} + - (IBAction)convertToH1:(id)sender { [self.editor makeHeaderForSelectedLinesWithLevel:1]; @@ -3676,6 +3864,25 @@ - (void)document:(NSDocument *)doc didPrint:(BOOL)ok context:(void *)context { if ([doc respondsToSelector:@selector(setPrinting:)]) ((MPDocument *)doc).printing = NO; + + // Issue #504: If this print completion was a save-to-PDF export, post- + // process the exported file to inject clickable internal anchor links. + // Gate on the stash, NOT on `context`: a normal Cmd-P print also has nil + // context, but never sets pdfExportURL, so it is correctly excluded here. + // Best-effort — annotation failures are never surfaced to the user, and + // the single-slot stash is always cleared once consumed. + MPDocument *mpDoc = (MPDocument *)doc; + if (mpDoc.pdfExportURL) + { + NSURL *exportURL = mpDoc.pdfExportURL; + @try { + if (ok) + [mpDoc postProcessExportedPDFAtURL:exportURL]; + } @finally { + mpDoc.pdfExportURL = nil; + } + } + if (context) { NSInvocation *invocation = (__bridge NSInvocation *)context; diff --git a/MacDown/Code/Document/MPPDFAnchorInjector.h b/MacDown/Code/Document/MPPDFAnchorInjector.h new file mode 100644 index 00000000..eb44978c --- /dev/null +++ b/MacDown/Code/Document/MPPDFAnchorInjector.h @@ -0,0 +1,63 @@ +// +// MPPDFAnchorInjector.h +// MacDown 3000 +// +// Pure, headless-testable engine that injects clickable internal-anchor +// link annotations into an already-rendered PDFDocument, using PDF-native +// text search for geometry (never DOM coordinates). +// +// Related to GitHub issue #504. +// + +#import +#import + +NS_ASSUME_NONNULL_BEGIN + +/** + * A single internal anchor link read from the rendered document, in + * document order: the visible link text (as it appears in the PDF, e.g. + * inside a table of contents) and the fragment slug it targets. + */ +@interface MPPDFAnchorLink : NSObject + +@property (nonatomic, copy, readonly) NSString *linkText; +@property (nonatomic, copy, readonly) NSString *targetSlug; + ++ (instancetype)linkWithText:(NSString *)linkText slug:(NSString *)targetSlug; + +@end + + +/** + * A single heading read from the rendered document, in document order: its + * anchor slug (the heading element's `id`) and its rendered text. + */ +@interface MPPDFAnchorHeading : NSObject + +@property (nonatomic, copy, readonly) NSString *slug; +@property (nonatomic, copy, readonly) NSString *headingText; + ++ (instancetype)headingWithSlug:(NSString *)slug text:(NSString *)headingText; + +@end + + +/** + * Injects clickable `.link` PDFAnnotations into `document` for each entry in + * `links` whose `targetSlug` resolves to a heading in `headings`, using + * PDF-native text search (`-[PDFDocument findString:withOptions:]`) to locate + * both the source rect (the rendered link text) and the destination rect + * (the rendered heading text). Mutates `document` in place. Never throws; + * unresolvable links are silently skipped. Returns the number of annotations + * added. + */ +@interface MPPDFAnchorInjector : NSObject + ++ (NSUInteger)injectLinksIntoDocument:(PDFDocument *)document + links:(NSArray *)links + headings:(NSArray *)headings; + +@end + +NS_ASSUME_NONNULL_END diff --git a/MacDown/Code/Document/MPPDFAnchorInjector.m b/MacDown/Code/Document/MPPDFAnchorInjector.m new file mode 100644 index 00000000..545b5627 --- /dev/null +++ b/MacDown/Code/Document/MPPDFAnchorInjector.m @@ -0,0 +1,292 @@ +// +// MPPDFAnchorInjector.m +// MacDown 3000 +// +// Related to GitHub issue #504. +// + +#import "MPPDFAnchorInjector.h" + + +#pragma mark - MPPDFAnchorLink + +@interface MPPDFAnchorLink () +@property (nonatomic, copy, readwrite) NSString *linkText; +@property (nonatomic, copy, readwrite) NSString *targetSlug; +@end + +@implementation MPPDFAnchorLink + ++ (instancetype)linkWithText:(NSString *)linkText slug:(NSString *)targetSlug +{ + MPPDFAnchorLink *link = [[self alloc] init]; + link.linkText = linkText; + link.targetSlug = targetSlug; + return link; +} + +@end + + +#pragma mark - MPPDFAnchorHeading + +@interface MPPDFAnchorHeading () +@property (nonatomic, copy, readwrite) NSString *slug; +@property (nonatomic, copy, readwrite) NSString *headingText; +@end + +@implementation MPPDFAnchorHeading + ++ (instancetype)headingWithSlug:(NSString *)slug text:(NSString *)headingText +{ + MPPDFAnchorHeading *heading = [[self alloc] init]; + heading.slug = slug; + heading.headingText = headingText; + return heading; +} + +@end + + +#pragma mark - MPPDFAnchorInjector Helpers + +// Treats nil, and whitespace/newline-only strings, as "blank". Used +// throughout so we never hand an empty needle to -[PDFDocument +// findString:withOptions:] (design §4 Step2 defensive guard) and never +// record an empty slug/heading-text pair in Step1. +static BOOL MPPDFAnchorStringIsBlank(NSString *string) +{ + if (string == nil) { + return YES; + } + NSString *trimmed = [string stringByTrimmingCharactersInSet: + [NSCharacterSet whitespaceAndNewlineCharacterSet]]; + return trimmed.length == 0; +} + +// h(sel) = NSHeight([sel boundsForPage:sel.pages.firstObject]), guarded: a +// selection with no pages (shouldn't happen in practice, but defensively +// possible) contributes a height of 0 rather than crashing. +static CGFloat MPPDFAnchorSelectionHeight(PDFSelection *selection) +{ + if (selection == nil) { + return 0.0; + } + NSArray *pages = selection.pages; + if (pages.count == 0) { + return 0.0; + } + PDFPage *page = pages.firstObject; + if (page == nil) { + return 0.0; + } + NSRect bounds = [selection boundsForPage:page]; + return NSHeight(bounds); +} + +#pragma mark - MPPDFAnchorInjector + +@implementation MPPDFAnchorInjector + ++ (NSUInteger)injectLinksIntoDocument:(PDFDocument *)document + links:(NSArray *)links + headings:(NSArray *)headings +{ + // Guard inputs (design §4): nil/zero-page document or empty links is a + // safe no-op. `links.count` on a nil `links` messages nil and yields 0, + // so this also covers a nil links array without a separate check. + if (document == nil || document.pageCount == 0 || links.count == 0) { + return 0; + } + + // Step 1: slug -> FIRST-match heading text. Headings with a blank slug + // or blank heading text are skipped and never recorded. + NSMutableDictionary *slugToHeadingText = [NSMutableDictionary dictionary]; + for (MPPDFAnchorHeading *heading in headings) { + if (heading == nil) { + continue; + } + NSString *slug = heading.slug; + NSString *text = heading.headingText; + if (MPPDFAnchorStringIsBlank(slug) || MPPDFAnchorStringIsBlank(text)) { + continue; + } + if (slugToHeadingText[slug] != nil) { + continue; // Keep only the first heading recorded per slug. + } + slugToHeadingText[slug] = text; + } + + // Step 2: distinct non-blank needle texts (every link's linkText, plus + // every destination text actually referenced by a link) get exactly one + // cached -findString:withOptions: call each. + NSMutableSet *needleTexts = [NSMutableSet set]; + for (MPPDFAnchorLink *link in links) { + if (link == nil) { + continue; + } + if (!MPPDFAnchorStringIsBlank(link.linkText)) { + [needleTexts addObject:link.linkText]; + } + NSString *destText = slugToHeadingText[link.targetSlug ?: @""]; + if (!MPPDFAnchorStringIsBlank(destText)) { + [needleTexts addObject:destText]; + } + } + + NSMutableDictionary *> *textToMatches = [NSMutableDictionary dictionary]; + for (NSString *needle in needleTexts) { + if (MPPDFAnchorStringIsBlank(needle)) { + continue; // Defensive: never search for a blank string. + } + NSArray *matches = nil; + @try { + matches = [document findString:needle withOptions:0]; + } @catch (NSException *exception) { + NSLog(@"[Issue #504] anchor link skipped due to exception: %@", exception); + matches = nil; + } + textToMatches[needle] = matches ?: @[]; + } + + // tocCount(T): number of links whose linkText == T, for any text T (used + // both for a link's own source text and for whatever destination text it + // resolves to). + NSMutableDictionary *tocCountByText = [NSMutableDictionary dictionary]; + for (MPPDFAnchorLink *link in links) { + if (link == nil || MPPDFAnchorStringIsBlank(link.linkText)) { + continue; + } + NSNumber *current = tocCountByText[link.linkText]; + tocCountByText[link.linkText] = @(current.unsignedIntegerValue + 1); + } + + NSUInteger addedCount = 0; + NSMutableDictionary *linkTextCounter = [NSMutableDictionary dictionary]; + + for (MPPDFAnchorLink *link in links) { + @try { + if (link == nil || MPPDFAnchorStringIsBlank(link.linkText)) { + continue; + } + + NSString *linkText = link.linkText; + NSArray *allMatches = textToMatches[linkText] ?: @[]; + NSUInteger tocCount = tocCountByText[linkText].unsignedIntegerValue; + NSUInteger tocAvailable = MIN(tocCount, allMatches.count); + NSArray *tocSelections = + [allMatches subarrayWithRange:NSMakeRange(0, tocAvailable)]; + + // Step 4: k-th link with this text maps to the k-th TOC + // selection of this text; the counter advances for every link + // with this text, matched or not, so later links with the same + // text still line up with later TOC occurrences. + NSUInteger k = linkTextCounter[linkText].unsignedIntegerValue; + linkTextCounter[linkText] = @(k + 1); + + if (k >= tocSelections.count) { + continue; // Not enough TOC occurrences for this link; skip. + } + + PDFSelection *sourceSel = tocSelections[k]; + if (sourceSel == nil) { + continue; + } + NSArray *sourcePages = sourceSel.pages; + if (sourcePages.count == 0) { + continue; + } + PDFPage *sourcePage = sourcePages.firstObject; + if (sourcePage == nil) { + continue; + } + NSRect sourceBounds = [sourceSel boundsForPage:sourcePage]; + CGFloat hSource = NSHeight(sourceBounds); + + // Resolve to the document's canonical PDFPage: annotations added to + // the transient page vended by PDFSelection.pages are not persisted + // on the page pageAtIndex: returns (and would not be written out). + // Issue #504. + NSUInteger sourcePageIndex = [document indexForPage:sourcePage]; + if (sourcePageIndex != NSNotFound) { + sourcePage = [document pageAtIndex:sourcePageIndex]; + } + + // Step 5: resolve the destination. + NSString *destText = slugToHeadingText[link.targetSlug ?: @""]; + if (MPPDFAnchorStringIsBlank(destText)) { + continue; // Unknown/empty-text slug target; skip. + } + + NSArray *destMatches = textToMatches[destText] ?: @[]; + NSUInteger destTocCount = tocCountByText[destText].unsignedIntegerValue; + NSUInteger destTocAvailable = MIN(destTocCount, destMatches.count); + NSArray *bodyGroup = + [destMatches subarrayWithRange:NSMakeRange(destTocAvailable, + destMatches.count - destTocAvailable)]; + if (bodyGroup.count == 0) { + continue; // No body occurrence to land on; skip. + } + + // (i) Preferred: first body occurrence taller than the source + // (a heading rendered larger than the TOC/body text). + PDFSelection *destSel = nil; + for (PDFSelection *candidate in bodyGroup) { + if (candidate == nil) { + continue; + } + if (MPPDFAnchorSelectionHeight(candidate) > hSource) { + destSel = candidate; + break; + } + } + // (ii) Fallback: first body occurrence in document order, so a + // same-size heading (e.g. default-theme h5/h6) is never dropped. + if (destSel == nil) { + destSel = bodyGroup.firstObject; + } + if (destSel == nil) { + continue; + } + + NSArray *destPages = destSel.pages; + if (destPages.count == 0) { + continue; + } + PDFPage *destPage = destPages.firstObject; + if (destPage == nil) { + continue; + } + NSRect destBounds = [destSel boundsForPage:destPage]; + + // Resolve to the document's canonical PDFPage for the same reason + // as sourcePage above: the PDFDestination must reference the page + // pageAtIndex: vends, or navigation/persistence would target a + // detached page wrapper. Issue #504. + NSUInteger destPageIndex = [document indexForPage:destPage]; + if (destPageIndex != NSNotFound) { + destPage = [document pageAtIndex:destPageIndex]; + } + + // Step 6: construct + attach the annotation. PDF pages are + // bottom-left origin, so the top of the heading is NSMaxY. + PDFDestination *destination = + [[PDFDestination alloc] initWithPage:destPage + atPoint:NSMakePoint(NSMinX(destBounds), NSMaxY(destBounds))]; + PDFAnnotation *annotation = + [[PDFAnnotation alloc] initWithBounds:sourceBounds + forType:PDFAnnotationSubtypeLink + withProperties:nil]; + annotation.action = [[PDFActionGoTo alloc] initWithDestination:destination]; + [sourcePage addAnnotation:annotation]; + addedCount++; + } @catch (NSException *exception) { + NSLog(@"[Issue #504] anchor link skipped due to exception: %@", exception); + continue; // One bad link must never abort the whole pass. + } + } + + return addedCount; +} + +@end diff --git a/MacDownTests/MPPDFAnchorInjectorTests.m b/MacDownTests/MPPDFAnchorInjectorTests.m new file mode 100644 index 00000000..28a27ac8 --- /dev/null +++ b/MacDownTests/MPPDFAnchorInjectorTests.m @@ -0,0 +1,1032 @@ +// +// MPPDFAnchorInjectorTests.m +// MacDownTests +// +// Headless (no WebView) tests for the pure PDF anchor-annotation engine +// described in the design for GitHub issue #504 ("Clickable Internal +// Anchor Links in Exported PDF"). +// +// Fixture PDFs are generated in-test with real, selectable text. The +// entire multi-page fixture is drawn into ONE tall NSView (see +// -documentFromDrawItems:drawnRects: below and MPPDFTestPrintView) which +// is handed to NSPrintOperation -- the exact same AppKit printing path +// MacDown's real "Export to PDF" feature uses. AppKit's automatic +// vertical pagination slices that single tall view into one printed page +// per page-height, and (unlike a raw, hand-rolled multi-page +// CGPDFContext, which was found to embed genuinely searchable text on +// only its FIRST page) this path reliably embeds correct, portable +// ToUnicode/encoding text on EVERY resulting page. There is no +// cross-document merge anywhere: the finished PDF file is loaded exactly +// once via -[PDFDocument initWithURL:], so every page -- not just page 0 +// -- carries genuinely searchable text that -[PDFDocument +// findString:withOptions:] can locate. No WebView is involved anywhere in +// this file. +// +// MPPDFAnchorInjector's +injectLinksIntoDocument:links:headings: is fully +// implemented (see MPPDFAnchorInjector.m): it uses -findString:withOptions: +// to locate both TOC/link source text and body/heading destination text, +// then disambiguates same-text collisions via document order and rendered +// height. Every behavioral assertion below is expected to PASS against +// that implementation. Tests 4 and 8 are the exception: they assert ZERO +// annotations for genuinely no-op/skip inputs (empty links array, empty +// document, and a slug whose heading text has no body occurrence) -- these +// are regression guards for the no-op/skip paths, not stub-refuting +// coverage. +// +// Related to GitHub issue #504. +// + +#import +#import +#import +#import +#import // for fabs(), used throughout the tolerance-based assertions below +#import "MPPDFAnchorInjector.h" + + +#pragma mark - Draw Item Helper + +// Describes one piece of text to draw into a fixture PDF: its content, font +// size, destination page, and its origin point measured from the TOP-LEFT +// of the page (y grows downward) -- i.e. ordinary "reading order" +// coordinates. The fixture builder converts this into the PDF's native +// bottom-left, y-up coordinate space when it draws the glyphs. +@interface MPPDFTestDrawItem : NSObject +@property (nonatomic, copy) NSString *text; +@property (nonatomic, assign) CGFloat fontSize; +@property (nonatomic, assign) NSUInteger pageIndex; +@property (nonatomic, assign) CGPoint topLeftPoint; ++ (instancetype)itemWithText:(NSString *)text + fontSize:(CGFloat)fontSize + pageIndex:(NSUInteger)pageIndex + topLeftPoint:(CGPoint)topLeftPoint; +@end + +@implementation MPPDFTestDrawItem ++ (instancetype)itemWithText:(NSString *)text + fontSize:(CGFloat)fontSize + pageIndex:(NSUInteger)pageIndex + topLeftPoint:(CGPoint)topLeftPoint +{ + MPPDFTestDrawItem *item = [[self alloc] init]; + item.text = text; + item.fontSize = fontSize; + item.pageIndex = pageIndex; + item.topLeftPoint = topLeftPoint; + return item; +} +@end + + +#pragma mark - Constants + +// US Letter, matching the media box used by every fixture page. +static const CGFloat kMPTestPageWidth = 612.0; +static const CGFloat kMPTestPageHeight = 792.0; + +// Font metrics vary slightly by rendering environment; tolerate a couple of +// points of slop when comparing rects/points, per design §7. +static const CGFloat kMPTestTolerance = 2.0; + + +#pragma mark - Print-Based Fixture View + +/** + * A single, TALL NSView holding every draw item for a whole fixture + * document, stacked page-by-page from top to bottom: its frame is + * `pageWidth` wide and `pageHeight * pageCount` tall. + * + * -documentFromDrawItems:drawnRects: hands this view to an + * NSPrintOperation configured with an NSPrintInfo whose paper size is + * exactly `pageWidth` x `pageHeight` and whose vertical pagination is + * automatic. That is exactly the AppKit printing path MacDown's own PDF + * export feature uses, and AppKit reliably slices this one tall view into + * `pageCount` separate printed pages, top-to-bottom -- unlike a raw, + * hand-rolled multi-page CGPDFContext, which was found (across 3 CI runs) + * to embed genuinely `-findString:withOptions:`-searchable text on only + * its FIRST page. + * + * The view is FLIPPED (`-isFlipped` returns YES), so (0, 0) is its + * top-left corner and y grows downward -- both across the whole tall view + * and within each `pageHeight`-tall slice. Each MPPDFTestDrawItem's own + * `topLeftPoint` is already specified in that same top-left, y-down + * "reading order" convention (see MPPDFTestDrawItem's header comment), so + * an item destined for `pageIndex` k is placed with a single addition: + * + * viewY = k * pageHeight + topLeftPoint.y + * viewX = topLeftPoint.x + * + * Because the print paper is exactly `pageHeight` tall and pagination is + * automatic/vertical-only, AppKit is guaranteed to cut the tall view into + * slices `[k * pageHeight, (k + 1) * pageHeight)` -- so every item whose + * `viewY` (as computed above) falls in slice k is printed on (0-indexed) + * page k, i.e. exactly the page the test author requested via + * `item.pageIndex`. + * + * In a FLIPPED view, `-[NSAttributedString drawAtPoint:]` places the + * string's TOP-left corner at the given point and the glyphs flow + * downward from there -- matching `topLeftPoint`'s own top-left, y-down + * convention exactly, so (unlike the old CGPDFContext path, which had to + * hand-convert top-left input into the PDF's native bottom-left/y-up space + * using measured font metrics) no coordinate flip or font-metrics + * prediction is needed here at all. + */ +@interface MPPDFTestPrintView : NSView +- (instancetype)initWithItems:(NSArray *)items + pageWidth:(CGFloat)pageWidth + pageHeight:(CGFloat)pageHeight + pageCount:(NSUInteger)pageCount; +@end + +@implementation MPPDFTestPrintView { + NSArray *_items; + CGFloat _pageHeight; +} + +- (instancetype)initWithItems:(NSArray *)items + pageWidth:(CGFloat)pageWidth + pageHeight:(CGFloat)pageHeight + pageCount:(NSUInteger)pageCount +{ + NSRect frame = NSMakeRect(0, 0, pageWidth, pageHeight * (CGFloat)pageCount); + self = [super initWithFrame:frame]; + if (self) { + _items = [items copy]; + _pageHeight = pageHeight; + } + return self; +} + +- (BOOL)isFlipped +{ + return YES; +} + +- (void)drawRect:(NSRect)dirtyRect +{ + [[NSColor whiteColor] setFill]; + NSRectFill(self.bounds); + + for (MPPDFTestDrawItem *item in _items) { + // Use a standard PDF base-14 font (Helvetica) rather than the + // private San Francisco system UI font: Helvetica is always + // present on macOS and reliably carries a correct + // ToUnicode/encoding mapping when printed to PDF, keeping the + // drawn glyphs text-extractable via + // -[PDFDocument findString:withOptions:]. + NSFont *font = [NSFont fontWithName:@"Helvetica" size:item.fontSize]; + if (!font) { + font = [NSFont userFontOfSize:item.fontSize]; // ultra-safe fallback + } + NSDictionary *attrs = @{NSFontAttributeName: font}; + NSAttributedString *attrString = [[NSAttributedString alloc] initWithString:item.text + attributes:attrs]; + + // See the class comment above: topLeftPoint is already top-left, + // y-down within its own page, so placing it in this flipped tall + // view is just an offset by that page's slice of pageHeight. + CGFloat viewX = item.topLeftPoint.x; + CGFloat viewY = (CGFloat)item.pageIndex * _pageHeight + item.topLeftPoint.y; + [attrString drawAtPoint:NSMakePoint(viewX, viewY)]; + } +} + +@end + + +#pragma mark - Test Case + +@interface MPPDFAnchorInjectorTests : XCTestCase +@end + + +@implementation MPPDFAnchorInjectorTests + +#pragma mark - Fixture Building + +/** + * Draws every item in `items` into a freshly generated, single 612x792 + * (US Letter) PDF document, one page per distinct pageIndex referenced by + * `items`. + * + * The WHOLE multi-page document is produced by printing ONE tall + * MPPDFTestPrintView (see its class comment above) through a real + * NSPrintOperation -- the same AppKit printing path MacDown's own PDF + * export feature uses -- rather than by hand-assembling a CGPDFContext or + * merging separate per-page PDFDocuments via `-insertPage:`. The + * NSPrintInfo's paper size is fixed at `kMPTestPageWidth` x + * `kMPTestPageHeight` with all margins zeroed, horizontal pagination set + * to fit the page width, and vertical pagination left automatic, so + * AppKit auto-paginates the tall view into one printed page per + * `kMPTestPageHeight` slice. The operation is run synchronously + * (`-runOperation`), saving directly to a unique temp file, which is then + * loaded exactly once via `-[PDFDocument initWithURL:]` and removed. + * + * Because this is the exact printing path known to embed correct, + * portable ToUnicode/encoding text on EVERY page (unlike a raw multi-page + * CGPDFContext, which was found across 3 CI runs to only keep page 0 + * genuinely searchable), and because Helvetica is one of the 14 standard + * PDF fonts with reliable, portable ToUnicode/encoding support (unlike the + * private San Francisco system font), the resulting text is + * `-findString:withOptions:`-searchable on EVERY page, not just page 0. + * + * After assembly, the document's `.string` is force-accessed (and the + * document is round-tripped once through `-dataRepresentation` / + * `-initWithData:`) to guarantee PDFKit has fully built its text index + * before any `-findString:withOptions:` call runs against it, eliminating + * any lazy-parse race. + * + * Returns the loaded PDFDocument. If `outDrawnRects` is non-NULL, it is set + * to an array of NSValue-wrapped CGRects (PDF/bottom-left coordinates), one + * per entry in `items`, in the SAME ORDER as `items` (independent of which + * page each item lands on). These rects are not predicted from font + * metrics -- they are measured GROUND TRUTH, located in the finished + * document via `-findString:withOptions:` and `-[PDFSelection + * boundsForPage:]`, the exact same mechanism the engine itself uses. This + * keeps the geometry assertions in tests 1/6/7 self-consistent with + * whatever PDFKit actually reports for the rendered glyphs, rather than + * depending on font-metrics prediction matching PDFKit's internal layout + * to within a couple of points. + */ +- (PDFDocument *)documentFromDrawItems:(NSArray *)items + drawnRects:(NSArray **)outDrawnRects +{ + NSUInteger pageCount = 1; + for (MPPDFTestDrawItem *item in items) { + pageCount = MAX(pageCount, item.pageIndex + 1); + } + + NSString *tempFileName = [NSString stringWithFormat:@"MPPDFAnchorInjectorTests-%@.pdf", + [[NSProcessInfo processInfo] globallyUniqueString]]; + NSURL *tempURL = [NSURL fileURLWithPath:[NSTemporaryDirectory() + stringByAppendingPathComponent:tempFileName]]; + + NSPrintInfo *printInfo = [[NSPrintInfo alloc] init]; + printInfo.paperSize = NSMakeSize(kMPTestPageWidth, kMPTestPageHeight); + printInfo.topMargin = 0; + printInfo.bottomMargin = 0; + printInfo.leftMargin = 0; + printInfo.rightMargin = 0; + printInfo.horizontalPagination = NSFitPagination; + printInfo.verticalPagination = NSAutoPagination; + printInfo.jobDisposition = NSPrintSaveJob; + [printInfo.dictionary setObject:tempURL forKey:NSPrintJobSavingURL]; + + // AppKit auto-paginates the tall view against the printer's IMAGEABLE + // page height, not the nominal paper height: the virtual "Save as PDF" + // printer imposes small fixed unprintable margins that zeroing the user + // margins does not remove, so the imageable height is a few points less + // than kMPTestPageHeight. Sizing the view by the nominal 792pt made the + // view an exact multiple of 792, but AppKit sliced it against the shorter + // imageable height and so produced pageCount+1 pages (off by exactly one). + // Size the view (and place items) by the ACTUAL imageable height so the + // tall view is exactly pageCount imageable-slices tall -> exactly pageCount + // pages, and each pageIndex-k item lands on printed page k. + CGFloat effectivePageHeight = printInfo.imageablePageBounds.size.height; + if (effectivePageHeight <= 0) { + effectivePageHeight = kMPTestPageHeight; + } + + MPPDFTestPrintView *printView = [[MPPDFTestPrintView alloc] initWithItems:items + pageWidth:kMPTestPageWidth + pageHeight:effectivePageHeight + pageCount:pageCount]; + + NSPrintOperation *printOperation = [NSPrintOperation printOperationWithView:printView + printInfo:printInfo]; + printOperation.showsPrintPanel = NO; + printOperation.showsProgressPanel = NO; + + BOOL ranSuccessfully = [printOperation runOperation]; + XCTAssertTrue(ranSuccessfully, @"NSPrintOperation failed to run while generating the fixture PDF"); + + NSFileManager *fileManager = [NSFileManager defaultManager]; + XCTAssertTrue([fileManager fileExistsAtPath:tempURL.path], + @"NSPrintOperation should have synchronously saved the fixture PDF to disk"); + + PDFDocument *document = [[PDFDocument alloc] initWithURL:tempURL]; + [fileManager removeItemAtPath:tempURL.path error:NULL]; + + XCTAssertNotNil(document, @"Failed to load the NSPrintOperation-generated fixture PDF"); + if (document == nil) { + return nil; + } + + XCTAssertEqual(document.pageCount, pageCount, + @"Fixture should have one page per requested pageIndex"); + + // Defensively force a full, synchronous text-index parse -- both on the + // freshly-loaded document and again after a round trip through + // -dataRepresentation/-initWithData: -- before this fixture is handed + // to the engine or measured below. + (void)document.string; + NSData *roundTripData = document.dataRepresentation; + PDFDocument *finalDocument = roundTripData ? [[PDFDocument alloc] initWithData:roundTripData] : nil; + if (finalDocument == nil) { + finalDocument = document; + } + (void)finalDocument.string; + + if (outDrawnRects) { + *outDrawnRects = [self measuredRectsForItems:items inDocument:finalDocument]; + } + + return finalDocument; +} + +/** + * Measures the GROUND-TRUTH rect (PDF/bottom-left coordinates) for every + * entry in `items` by locating its drawn text in the already-assembled + * `document` via `-findString:withOptions:`, exactly as the engine under + * test does. Results are returned in the same order as `items`. + * + * When the same text is drawn more than once on the same page (e.g. two + * identical TOC entries), occurrences are matched to items in draw order: + * the Nth item requesting a given (text, pageIndex) pair is matched to the + * Nth matching selection found on that page, which mirrors the vertical + * stacking order the items were drawn in (and the order PDFKit's own text + * extraction reports them, since it works out reading order top-to-bottom). + */ +- (NSArray *)measuredRectsForItems:(NSArray *)items + inDocument:(PDFDocument *)document +{ + NSMutableDictionary *> *matchesByText = [NSMutableDictionary dictionary]; + NSMutableDictionary *consumedOnPage = [NSMutableDictionary dictionary]; + NSMutableArray *rects = [NSMutableArray arrayWithCapacity:items.count]; + + for (MPPDFTestDrawItem *item in items) { + NSArray *matches = matchesByText[item.text]; + if (matches == nil) { + matches = [document findString:item.text withOptions:0] ?: @[]; + matchesByText[item.text] = matches; + } + + NSMutableArray *onThisPage = [NSMutableArray array]; + for (PDFSelection *selection in matches) { + NSArray *pages = selection.pages; + if (pages.count == 0) { + continue; + } + PDFPage *page = pages.firstObject; + if (page != nil && [document indexForPage:page] == item.pageIndex) { + [onThisPage addObject:selection]; + } + } + + NSString *counterKey = [NSString stringWithFormat:@"%@|%lu", item.text, + (unsigned long)item.pageIndex]; + NSUInteger occurrenceIndex = consumedOnPage[counterKey].unsignedIntegerValue; + consumedOnPage[counterKey] = @(occurrenceIndex + 1); + + CGRect rect = CGRectZero; + if (occurrenceIndex < onThisPage.count) { + PDFSelection *selection = onThisPage[occurrenceIndex]; + PDFPage *page = selection.pages.firstObject; + rect = NSRectToCGRect([selection boundsForPage:page]); + } else { + XCTFail(@"Could not locate drawn text '%@' on fixture page %lu via findString: -- " + @"the fixture's text may not be searchable", item.text, (unsigned long)item.pageIndex); + } + + [rects addObject:[NSValue valueWithRect:NSRectFromCGRect(rect)]]; + } + + return rects; +} + +/** + * Convenience wrapper (design §7): TOC entries drawn one per line at 12pt + * on page 0; each body heading drawn at 24pt alone on its own subsequent + * page (page 1, 2, ...), in the order given. Used by tests 1-4. + */ +- (PDFDocument *)fixtureWithTOC:(NSArray *)toc + bodyHeadings:(NSArray *)headings + tocRects:(NSArray **)outTocRects + headingRects:(NSArray **)outHeadingRects +{ + NSMutableArray *items = [NSMutableArray array]; + + CGFloat tocY = 72.0; + for (NSString *entry in toc) { + [items addObject:[MPPDFTestDrawItem itemWithText:entry + fontSize:12.0 + pageIndex:0 + topLeftPoint:CGPointMake(72.0, tocY)]]; + tocY += 24.0; + } + + NSUInteger pageIndex = 1; + for (NSString *heading in headings) { + [items addObject:[MPPDFTestDrawItem itemWithText:heading + fontSize:24.0 + pageIndex:pageIndex + topLeftPoint:CGPointMake(72.0, 72.0)]]; + pageIndex++; + } + + NSArray *allRects = nil; + PDFDocument *document = [self documentFromDrawItems:items drawnRects:&allRects]; + + if (outTocRects) { + *outTocRects = [allRects subarrayWithRange:NSMakeRange(0, toc.count)]; + } + if (outHeadingRects) { + *outHeadingRects = [allRects subarrayWithRange:NSMakeRange(toc.count, headings.count)]; + } + return document; +} + +#pragma mark - Assertion Helpers + +- (NSArray *)linkAnnotationsOnPage:(PDFPage *)page +{ + NSMutableArray *result = [NSMutableArray array]; + for (PDFAnnotation *annotation in page.annotations) { + if ([annotation.type isEqualToString:PDFAnnotationSubtypeLink]) { + [result addObject:annotation]; + } + } + return result; +} + +- (NSUInteger)totalLinkAnnotationsInDocument:(PDFDocument *)document +{ + NSUInteger total = 0; + for (NSUInteger i = 0; i < document.pageCount; i++) { + total += [self linkAnnotationsOnPage:[document pageAtIndex:i]].count; + } + return total; +} + +#pragma mark - Test 1: Happy Path + +/** + * TOC ["Intro", "Details"] on page 0, headings ["Intro", "Details"] on + * pages 1 and 2 respectively. After injection, page 0 must carry exactly 2 + * link annotations, each a real PDFActionGoTo landing on the matching + * heading page at (approximately) the top of the heading's rect. + */ +- (void)testHappyPathInjectsClickableLinksToCorrectHeadingPages +{ + NSArray *tocRects = nil; + NSArray *headingRects = nil; + PDFDocument *document = [self fixtureWithTOC:@[@"Intro", @"Details"] + bodyHeadings:@[@"Intro", @"Details"] + tocRects:&tocRects + headingRects:&headingRects]; + + NSArray *links = @[ + [MPPDFAnchorLink linkWithText:@"Intro" slug:@"intro"], + [MPPDFAnchorLink linkWithText:@"Details" slug:@"details"], + ]; + NSArray *headings = @[ + [MPPDFAnchorHeading headingWithSlug:@"intro" text:@"Intro"], + [MPPDFAnchorHeading headingWithSlug:@"details" text:@"Details"], + ]; + + NSUInteger added = [MPPDFAnchorInjector injectLinksIntoDocument:document + links:links + headings:headings]; + XCTAssertEqual(added, (NSUInteger)2, @"Should inject exactly 2 annotations for 2 TOC links"); + + PDFPage *tocPage = [document pageAtIndex:0]; + NSArray *tocAnnotations = [self linkAnnotationsOnPage:tocPage]; + XCTAssertEqual(tocAnnotations.count, (NSUInteger)2, + @"TOC page should have exactly 2 link annotations"); + + NSArray *expectedHeadingPageIndexes = @[@1, @2]; + + // Match each injected annotation back to its expected TOC entry by + // source-rect proximity, since injected annotation order on a page is + // not otherwise part of the contract. + for (NSUInteger i = 0; i < 2; i++) { + CGRect expectedTocRect = NSRectToCGRect([tocRects[i] rectValue]); + CGRect expectedHeadingRect = NSRectToCGRect([headingRects[i] rectValue]); + NSUInteger expectedPageIndex = [expectedHeadingPageIndexes[i] unsignedIntegerValue]; + + PDFAnnotation *match = nil; + for (PDFAnnotation *annotation in tocAnnotations) { + if (fabs(NSMinX(annotation.bounds) - CGRectGetMinX(expectedTocRect)) <= kMPTestTolerance && + fabs(NSMinY(annotation.bounds) - CGRectGetMinY(expectedTocRect)) <= kMPTestTolerance) { + match = annotation; + break; + } + } + XCTAssertNotNil(match, @"No injected annotation found matching TOC rect for entry %lu", + (unsigned long)i); + if (!match) { + continue; + } + + XCTAssertEqualObjects(match.type, PDFAnnotationSubtypeLink, + @"Injected annotation %lu should be a link annotation", (unsigned long)i); + XCTAssertTrue([match.action isKindOfClass:[PDFActionGoTo class]], + @"Injected annotation %lu's action should be a PDFActionGoTo", (unsigned long)i); + if (![match.action isKindOfClass:[PDFActionGoTo class]]) { + continue; + } + + PDFActionGoTo *goTo = (PDFActionGoTo *)match.action; + PDFDestination *destination = goTo.destination; + NSUInteger actualPageIndex = [document indexForPage:destination.page]; + XCTAssertEqual(actualPageIndex, expectedPageIndex, + @"Link %lu should navigate to the correct heading page", (unsigned long)i); + XCTAssertEqualWithAccuracy(destination.point.y, CGRectGetMaxY(expectedHeadingRect), kMPTestTolerance, + @"Destination point should land at (about) the top of the heading rect"); + XCTAssertEqualWithAccuracy(destination.point.x, CGRectGetMinX(expectedHeadingRect), kMPTestTolerance, + @"Destination point.x should land at (about) the left edge of the heading " + @"rect (design Step6 sets point.x = NSMinX(destBounds))"); + } +} + +#pragma mark - Test 2: Collision / First-Match + +/** + * Two body headings share identical text ("Dup", same slug "dup") on + * pages 1 and 2. The single TOC link targeting "dup" must resolve to the + * FIRST body occurrence (page 1), per the collision rule. + */ +- (void)testDuplicateHeadingTextResolvesToFirstBodyOccurrence +{ + NSMutableArray *items = [NSMutableArray array]; + [items addObject:[MPPDFTestDrawItem itemWithText:@"Dup" fontSize:12.0 pageIndex:0 + topLeftPoint:CGPointMake(72, 72)]]; + [items addObject:[MPPDFTestDrawItem itemWithText:@"Dup" fontSize:24.0 pageIndex:1 + topLeftPoint:CGPointMake(72, 72)]]; + [items addObject:[MPPDFTestDrawItem itemWithText:@"Dup" fontSize:24.0 pageIndex:2 + topLeftPoint:CGPointMake(72, 72)]]; + + NSArray *rects = nil; + PDFDocument *document = [self documentFromDrawItems:items drawnRects:&rects]; + + NSArray *links = @[[MPPDFAnchorLink linkWithText:@"Dup" slug:@"dup"]]; + NSArray *headings = @[ + [MPPDFAnchorHeading headingWithSlug:@"dup" text:@"Dup"], + [MPPDFAnchorHeading headingWithSlug:@"dup" text:@"Dup"], + ]; + + NSUInteger added = [MPPDFAnchorInjector injectLinksIntoDocument:document links:links headings:headings]; + XCTAssertEqual(added, (NSUInteger)1, @"Exactly one annotation should be injected for the single TOC link"); + + PDFPage *tocPage = [document pageAtIndex:0]; + NSArray *tocAnnotations = [self linkAnnotationsOnPage:tocPage]; + XCTAssertEqual(tocAnnotations.count, (NSUInteger)1); + if (tocAnnotations.count != 1) { + return; + } + + PDFAnnotation *annotation = tocAnnotations.firstObject; + XCTAssertTrue([annotation.action isKindOfClass:[PDFActionGoTo class]]); + if (![annotation.action isKindOfClass:[PDFActionGoTo class]]) { + return; + } + PDFActionGoTo *goTo = (PDFActionGoTo *)annotation.action; + NSUInteger destinationPageIndex = [document indexForPage:goTo.destination.page]; + XCTAssertEqual(destinationPageIndex, (NSUInteger)1, + @"Colliding slugs must resolve to the FIRST body occurrence (page 1), not page 2"); +} + +#pragma mark - Test 3: No-Match / Inert-Safe + +/** + * Mixes one resolvable link ("Real" -> heading "real") with: + * - a link whose targetSlug ("unknown-slug") has no matching heading, + * - a link whose linkText ("NeverDrawn") does not appear anywhere in the + * PDF at all, and + * - a link ("EmptyTarget" -> "empty-heading") whose target heading has + * EMPTY text. Per design §4 Step5, destText resolves to "" for this + * slug; an empty heading text must never be used as a findString: + * needle (it would nonsensically match everywhere), so the engine must + * SKIP this link rather than search for "". + * None of the unresolved cases may add an annotation, throw, or perturb + * the one valid link's annotation. + */ +- (void)testUnmatchedAndUnfoundLinksProduceNoAnnotationWithoutPerturbingValidOnes +{ + NSMutableArray *items = [NSMutableArray array]; + [items addObject:[MPPDFTestDrawItem itemWithText:@"Real" fontSize:12.0 pageIndex:0 + topLeftPoint:CGPointMake(72, 72)]]; + [items addObject:[MPPDFTestDrawItem itemWithText:@"Ghost" fontSize:12.0 pageIndex:0 + topLeftPoint:CGPointMake(72, 96)]]; + [items addObject:[MPPDFTestDrawItem itemWithText:@"EmptyTarget" fontSize:12.0 pageIndex:0 + topLeftPoint:CGPointMake(72, 120)]]; + // Placed well clear of the top-of-page boundary (Issue #504): a couple + // of CI runs showed this occurrence intermittently not text-searchable + // when drawn right at the imageable-area edge, so it is drawn further + // down the page than the bare 72pt used elsewhere, giving it a + // comfortable margin on every side. + [items addObject:[MPPDFTestDrawItem itemWithText:@"Real" fontSize:24.0 pageIndex:1 + topLeftPoint:CGPointMake(72, 200)]]; + + NSArray *rects = nil; + PDFDocument *document = [self documentFromDrawItems:items drawnRects:&rects]; + + NSArray *links = @[ + [MPPDFAnchorLink linkWithText:@"Real" slug:@"real"], + [MPPDFAnchorLink linkWithText:@"Ghost" slug:@"unknown-slug"], + [MPPDFAnchorLink linkWithText:@"NeverDrawn" slug:@"never-drawn-slug"], + [MPPDFAnchorLink linkWithText:@"EmptyTarget" slug:@"empty-heading"], + ]; + NSArray *headings = @[ + [MPPDFAnchorHeading headingWithSlug:@"real" text:@"Real"], + [MPPDFAnchorHeading headingWithSlug:@"empty-heading" text:@""], + ]; + + __block NSUInteger added = 0; + XCTAssertNoThrow(added = [MPPDFAnchorInjector injectLinksIntoDocument:document + links:links + headings:headings]); + + XCTAssertEqual(added, (NSUInteger)1, + @"Only the 'Real' link should resolve; unknown-slug, never-drawn, and " + @"empty-heading-target links must all be skipped"); + XCTAssertEqual([self totalLinkAnnotationsInDocument:document], (NSUInteger)1, + @"No-match/empty-heading links must not add annotations, and must not disturb " + @"the one valid link"); + + PDFPage *tocPage = [document pageAtIndex:0]; + NSArray *tocAnnotations = [self linkAnnotationsOnPage:tocPage]; + XCTAssertEqual(tocAnnotations.count, (NSUInteger)1); + if (tocAnnotations.count == 1 && [tocAnnotations.firstObject.action isKindOfClass:[PDFActionGoTo class]]) { + PDFActionGoTo *goTo = (PDFActionGoTo *)tocAnnotations.firstObject.action; + XCTAssertEqual([document indexForPage:goTo.destination.page], (NSUInteger)1, + @"The one valid link must still resolve to the correct heading page"); + } +} + +#pragma mark - Test 4: No-Op + +/** + * (a) A real, non-trivial document with an EMPTY links array must add + * zero annotations. + * (b) A genuinely empty (zero-page) PDFDocument, even given non-empty + * links/headings, must add zero annotations and must not throw. + * + * This is a regression guard for the engine's early no-op guards (an empty + * `links` array, or a zero-page document), so that a future change can + * never make either case spuriously produce annotations. + */ +- (void)testEmptyLinksAndEmptyDocumentAreSafeNoOps +{ + // (a) Real document, but an empty links array. + PDFDocument *document = [self fixtureWithTOC:@[@"Intro"] + bodyHeadings:@[@"Intro"] + tocRects:NULL + headingRects:NULL]; + NSArray *headings = @[[MPPDFAnchorHeading headingWithSlug:@"intro" text:@"Intro"]]; + + __block NSUInteger added = 99; + XCTAssertNoThrow(added = [MPPDFAnchorInjector injectLinksIntoDocument:document + links:@[] + headings:headings]); + XCTAssertEqual(added, (NSUInteger)0, @"Empty links array must add zero annotations"); + XCTAssertEqual([self totalLinkAnnotationsInDocument:document], (NSUInteger)0, + @"Empty links array must not mutate the document"); + + // (b) A genuinely empty (zero-page) PDFDocument. + PDFDocument *emptyDocument = [[PDFDocument alloc] init]; + XCTAssertEqual(emptyDocument.pageCount, (NSUInteger)0, + @"Sanity check: a freshly-init PDFDocument has no pages"); + + NSArray *links = @[[MPPDFAnchorLink linkWithText:@"Intro" slug:@"intro"]]; + __block NSUInteger addedToEmpty = 99; + XCTAssertNoThrow(addedToEmpty = [MPPDFAnchorInjector injectLinksIntoDocument:emptyDocument + links:links + headings:headings]); + XCTAssertEqual(addedToEmpty, (NSUInteger)0, @"An empty PDFDocument must yield zero injected annotations"); +} + +#pragma mark - Test 5: Same-Size Fallback + +/** + * The TOC entry and its target heading are BOTH drawn at 14pt (mimicking + * the default-theme h5/h6 case, where headings render at body size). The + * document-order fallback (design §4 Step5(ii)) must still inject an + * annotation, since height alone cannot distinguish heading from TOC/body + * text here. + */ +- (void)testSameSizeHeadingStillResolvesViaDocumentOrderFallback +{ + NSMutableArray *items = [NSMutableArray array]; + [items addObject:[MPPDFTestDrawItem itemWithText:@"Sub" fontSize:14.0 pageIndex:0 + topLeftPoint:CGPointMake(72, 72)]]; + [items addObject:[MPPDFTestDrawItem itemWithText:@"Sub" fontSize:14.0 pageIndex:1 + topLeftPoint:CGPointMake(72, 72)]]; + + NSArray *rects = nil; + PDFDocument *document = [self documentFromDrawItems:items drawnRects:&rects]; + + NSArray *links = @[[MPPDFAnchorLink linkWithText:@"Sub" slug:@"sub"]]; + NSArray *headings = @[[MPPDFAnchorHeading headingWithSlug:@"sub" text:@"Sub"]]; + + NSUInteger added = [MPPDFAnchorInjector injectLinksIntoDocument:document links:links headings:headings]; + XCTAssertEqual(added, (NSUInteger)1, + @"Same-size TOC entry and heading (default-theme h5/h6 case) must still resolve " + @"via the document-order fallback"); + + PDFPage *tocPage = [document pageAtIndex:0]; + NSArray *tocAnnotations = [self linkAnnotationsOnPage:tocPage]; + XCTAssertEqual(tocAnnotations.count, (NSUInteger)1); + if (tocAnnotations.count == 1 && [tocAnnotations.firstObject.action isKindOfClass:[PDFActionGoTo class]]) { + PDFActionGoTo *goTo = (PDFActionGoTo *)tocAnnotations.firstObject.action; + XCTAssertEqual([document indexForPage:goTo.destination.page], (NSUInteger)1, + @"Fallback destination should be the (only) body occurrence, on page 1"); + } +} + +#pragma mark - Test 6: Height Promotes Heading Over Preceding Prose + +/** + * The destination text "Target" appears three times: a 12pt TOC entry on + * page 0, a 12pt body PROSE occurrence (same size as the TOC entry) on + * page 1, and the real 24pt HEADING on page 2. + * + * INVARIANT (design §4 Step4/Step5): hSource is the height of the TOC + * entry's rendered text, which in the real app EQUALS body-prose height -- + * a TOC entry is ordinary body-size link text, and body paragraphs render + * at that same body font size. Only text rendered LARGER than body size + * (an actual heading) has height > hSource. This fixture reflects that + * invariant explicitly: TOC entry == prose size (12pt) < heading size + * (24pt). Because the prose is NOT taller than hSource, Step5(i)'s + * `height > hSource` test correctly steps over it and promotes the + * page-2, 24pt heading -- the first (and only) body occurrence that is + * actually taller than the TOC entry. + */ +- (void)testHeightPromotesRealHeadingOverPrecedingSameTextProse +{ + NSMutableArray *items = [NSMutableArray array]; + [items addObject:[MPPDFTestDrawItem itemWithText:@"Target" fontSize:12.0 pageIndex:0 + topLeftPoint:CGPointMake(72, 72)]]; // TOC entry (body size) + [items addObject:[MPPDFTestDrawItem itemWithText:@"Target" fontSize:12.0 pageIndex:1 + topLeftPoint:CGPointMake(72, 72)]]; // body prose, SAME size as TOC entry + [items addObject:[MPPDFTestDrawItem itemWithText:@"Target" fontSize:24.0 pageIndex:2 + topLeftPoint:CGPointMake(72, 72)]]; // the real heading, LARGER than body size + + NSArray *rects = nil; + PDFDocument *document = [self documentFromDrawItems:items drawnRects:&rects]; + + NSArray *links = @[[MPPDFAnchorLink linkWithText:@"Target" slug:@"target"]]; + NSArray *headings = @[[MPPDFAnchorHeading headingWithSlug:@"target" text:@"Target"]]; + + NSUInteger added = [MPPDFAnchorInjector injectLinksIntoDocument:document links:links headings:headings]; + XCTAssertEqual(added, (NSUInteger)1); + + PDFPage *tocPage = [document pageAtIndex:0]; + NSArray *tocAnnotations = [self linkAnnotationsOnPage:tocPage]; + XCTAssertEqual(tocAnnotations.count, (NSUInteger)1); + if (tocAnnotations.count == 1 && [tocAnnotations.firstObject.action isKindOfClass:[PDFActionGoTo class]]) { + PDFActionGoTo *goTo = (PDFActionGoTo *)tocAnnotations.firstObject.action; + NSUInteger destinationPageIndex = [document indexForPage:goTo.destination.page]; + XCTAssertEqual(destinationPageIndex, (NSUInteger)2, + @"Destination must resolve to the larger 24pt heading on page 2, " + @"not the same-body-size (12pt) prose on page 1"); + } +} + +#pragma mark - Test 7: Multiple Links To Same Slug + +/** + * The SAME TOC entry text ("Intro") appears twice in the TOC -- two + * separate MPPDFAnchorLinks, both with linkText "Intro" and both + * targeting slug "intro" -- alongside exactly one body heading "Intro". + * This models a document where a heading is cross-referenced from more + * than one place. Per design §4 Step6, every link that targets a given + * slug must reuse the SAME cached first-match destination ("multiple + * links to same slug -> same first-match dest"), and per Step4 each link + * still gets its OWN source annotation, aligned to its own TOC + * occurrence in document order (the k-th link with text T binds to the + * k-th TOC selection of T). + * + * Assert: exactly 2 link annotations are added, one anchored at each of + * the two distinct TOC source rects, and BOTH resolve to the identical + * destination (same page and same point) -- the sole "Intro" heading. + */ +- (void)testMultipleLinksToSameSlugShareFirstMatchDestination +{ + NSArray *tocRects = nil; + NSArray *headingRects = nil; + PDFDocument *document = [self fixtureWithTOC:@[@"Intro", @"Intro"] + bodyHeadings:@[@"Intro"] + tocRects:&tocRects + headingRects:&headingRects]; + + NSArray *links = @[ + [MPPDFAnchorLink linkWithText:@"Intro" slug:@"intro"], + [MPPDFAnchorLink linkWithText:@"Intro" slug:@"intro"], + ]; + NSArray *headings = @[ + [MPPDFAnchorHeading headingWithSlug:@"intro" text:@"Intro"], + ]; + + NSUInteger added = [MPPDFAnchorInjector injectLinksIntoDocument:document links:links headings:headings]; + XCTAssertEqual(added, (NSUInteger)2, @"Both links to the same slug must each get their own annotation"); + + PDFPage *tocPage = [document pageAtIndex:0]; + NSArray *tocAnnotations = [self linkAnnotationsOnPage:tocPage]; + XCTAssertEqual(tocAnnotations.count, (NSUInteger)2, + @"TOC page should have exactly 2 link annotations, one per TOC occurrence"); + if (tocAnnotations.count != 2) { + return; + } + + CGRect expectedHeadingRect = NSRectToCGRect([headingRects[0] rectValue]); + NSPoint firstDestinationPoint = NSZeroPoint; + NSUInteger firstDestinationPageIndex = NSNotFound; + + // Match each injected annotation back to its expected TOC occurrence by + // source-rect proximity (same technique as test 1), then verify every + // one shares the exact same destination. + for (NSUInteger i = 0; i < 2; i++) { + CGRect expectedTocRect = NSRectToCGRect([tocRects[i] rectValue]); + + PDFAnnotation *match = nil; + for (PDFAnnotation *annotation in tocAnnotations) { + if (fabs(NSMinX(annotation.bounds) - CGRectGetMinX(expectedTocRect)) <= kMPTestTolerance && + fabs(NSMinY(annotation.bounds) - CGRectGetMinY(expectedTocRect)) <= kMPTestTolerance) { + match = annotation; + break; + } + } + XCTAssertNotNil(match, @"No injected annotation found matching TOC rect for occurrence %lu", + (unsigned long)i); + if (!match) { + continue; + } + XCTAssertTrue([match.action isKindOfClass:[PDFActionGoTo class]], + @"Injected annotation %lu's action should be a PDFActionGoTo", (unsigned long)i); + if (![match.action isKindOfClass:[PDFActionGoTo class]]) { + continue; + } + + PDFActionGoTo *goTo = (PDFActionGoTo *)match.action; + PDFDestination *destination = goTo.destination; + NSUInteger actualPageIndex = [document indexForPage:destination.page]; + XCTAssertEqual(actualPageIndex, (NSUInteger)1, + @"Both links should navigate to the single heading's page (1)"); + XCTAssertEqualWithAccuracy(destination.point.y, CGRectGetMaxY(expectedHeadingRect), kMPTestTolerance, + @"Destination point.y should land at (about) the top of the heading rect"); + XCTAssertEqualWithAccuracy(destination.point.x, CGRectGetMinX(expectedHeadingRect), kMPTestTolerance, + @"Destination point.x should land at (about) the left edge of the heading rect"); + + if (i == 0) { + firstDestinationPoint = destination.point; + firstDestinationPageIndex = actualPageIndex; + } else { + XCTAssertEqual(actualPageIndex, firstDestinationPageIndex, + @"Every link to the same slug must resolve to the exact same destination page"); + XCTAssertEqualWithAccuracy(destination.point.x, firstDestinationPoint.x, kMPTestTolerance, + @"Every link to the same slug must resolve to the exact same destination point.x"); + XCTAssertEqualWithAccuracy(destination.point.y, firstDestinationPoint.y, kMPTestTolerance, + @"Every link to the same slug must resolve to the exact same destination point.y"); + } + } +} + +#pragma mark - Test 8: bodyGroup-Empty Skip Path + +/** + * A link whose targetSlug IS matched by a heading in the model, but whose + * heading text NEVER appears in the PDF as a body occurrence -- only as + * the TOC entry itself. Only the TOC entry "Ghost" is drawn (body size, + * page 0); no body heading text is drawn anywhere in the document. + * + * Per design §4 Step3, matches["Ghost"] contains exactly ONE occurrence + * (the TOC entry); tocCount("Ghost") == 1 (one link with that linkText), + * so bodyGroup = matches["Ghost"] AFTER the first 1 occurrence == the + * empty remainder. Step5 explicitly SKIPs when bodyGroup is empty. This + * exercises the distinct "bodyGroup empty -> SKIP" branch, which is + * different from test 3's "no matching heading for slug" (destText nil) + * and "linkText never drawn at all" (matches[T] itself empty) skip paths. + * + * Assert: 0 annotations added, no throw. + * + * Like test 4, this expected outcome (0 annotations) is a regression guard + * for the engine's bodyGroup-empty skip branch, not a count that varies + * with the implementation. + */ +- (void)testBodyGroupEmptySkipsLinkWithoutThrowing +{ + NSMutableArray *items = [NSMutableArray array]; + [items addObject:[MPPDFTestDrawItem itemWithText:@"Ghost" fontSize:12.0 pageIndex:0 + topLeftPoint:CGPointMake(72, 72)]]; // TOC entry only; no body occurrence anywhere + + NSArray *rects = nil; + PDFDocument *document = [self documentFromDrawItems:items drawnRects:&rects]; + + NSArray *links = @[[MPPDFAnchorLink linkWithText:@"Ghost" slug:@"ghost"]]; + NSArray *headings = @[[MPPDFAnchorHeading headingWithSlug:@"ghost" text:@"Ghost"]]; + + __block NSUInteger added = 99; + XCTAssertNoThrow(added = [MPPDFAnchorInjector injectLinksIntoDocument:document + links:links + headings:headings]); + XCTAssertEqual(added, (NSUInteger)0, + @"A slug whose heading text never appears as a body occurrence (bodyGroup empty) " + @"must be skipped, not crash or partially annotate"); + XCTAssertEqual([self totalLinkAnnotationsInDocument:document], (NSUInteger)0, + @"No annotation should be added anywhere in the document"); +} + +#pragma mark - Test 9: Blank linkText Is Skipped + +/** + * A link with a BLANK linkText (empty string) alongside a resolvable + * "Real" link targeting the same, genuinely-drawn "Real" heading. Per + * design §4 Step2/Step4, MPPDFAnchorStringIsBlank(link.linkText) must + * short-circuit before any findString: lookup is attempted for that link, + * so it can never be searched for, never throws, and never adds an + * annotation -- while the other, valid link is completely unaffected. + */ +- (void)testBlankLinkTextIsSkippedWithoutAffectingValidLink +{ + PDFDocument *document = [self fixtureWithTOC:@[@"Real"] + bodyHeadings:@[@"Real"] + tocRects:NULL + headingRects:NULL]; + + NSArray *links = @[ + [MPPDFAnchorLink linkWithText:@"" slug:@"real"], + [MPPDFAnchorLink linkWithText:@"Real" slug:@"real"], + ]; + NSArray *headings = @[ + [MPPDFAnchorHeading headingWithSlug:@"real" text:@"Real"], + ]; + + __block NSUInteger added = 99; + XCTAssertNoThrow(added = [MPPDFAnchorInjector injectLinksIntoDocument:document + links:links + headings:headings]); + XCTAssertEqual(added, (NSUInteger)1, + @"The blank-linkText link must be silently skipped; the resolvable 'Real' link " + @"must still produce exactly one annotation"); + XCTAssertEqual([self totalLinkAnnotationsInDocument:document], (NSUInteger)1, + @"Only the valid link's annotation should exist anywhere in the document"); + + PDFPage *tocPage = [document pageAtIndex:0]; + NSArray *tocAnnotations = [self linkAnnotationsOnPage:tocPage]; + XCTAssertEqual(tocAnnotations.count, (NSUInteger)1); + if (tocAnnotations.count == 1 && [tocAnnotations.firstObject.action isKindOfClass:[PDFActionGoTo class]]) { + PDFActionGoTo *goTo = (PDFActionGoTo *)tocAnnotations.firstObject.action; + XCTAssertEqual([document indexForPage:goTo.destination.page], (NSUInteger)1, + @"The valid 'Real' link must still resolve to the correct heading page"); + } +} + +#pragma mark - Test 10: Partial TOC-Occurrence Mismatch + +/** + * Two MPPDFAnchorLinks share the SAME linkText ("Repeat"), but the PDF + * contains only ONE drawn occurrence of that text (a single TOC-style + * entry on page 0) -- there is no second "Repeat" anywhere in the document. + * Both links target a slug that resolves to a genuinely-drawn heading + * ("Target" on page 1), so the shared destText is resolvable; the only + * thing limiting the second link is the shortage of source occurrences. + * + * Per design §4 Step4, tocAvailable = MIN(tocCount("Repeat")=2, + * allMatches.count=1) = 1, so only ONE TOC selection exists for "Repeat". + * The first link (k=0) binds to it and resolves normally. The second link + * (k=1) hits `k >= tocSelections.count` and must be silently skipped: no + * throw, no annotation, and the first link's annotation must be completely + * unaffected. + */ +- (void)testSecondLinkWithSharedTextButNoSecondOccurrenceIsSkipped +{ + NSMutableArray *items = [NSMutableArray array]; + [items addObject:[MPPDFTestDrawItem itemWithText:@"Repeat" fontSize:12.0 pageIndex:0 + topLeftPoint:CGPointMake(72, 72)]]; // sole "Repeat" occurrence + [items addObject:[MPPDFTestDrawItem itemWithText:@"Target" fontSize:24.0 pageIndex:1 + topLeftPoint:CGPointMake(72, 72)]]; // the heading both links target + + NSArray *rects = nil; + PDFDocument *document = [self documentFromDrawItems:items drawnRects:&rects]; + + NSArray *links = @[ + [MPPDFAnchorLink linkWithText:@"Repeat" slug:@"target-heading"], + [MPPDFAnchorLink linkWithText:@"Repeat" slug:@"target-heading"], + ]; + NSArray *headings = @[ + [MPPDFAnchorHeading headingWithSlug:@"target-heading" text:@"Target"], + ]; + + __block NSUInteger added = 99; + XCTAssertNoThrow(added = [MPPDFAnchorInjector injectLinksIntoDocument:document + links:links + headings:headings]); + XCTAssertEqual(added, (NSUInteger)1, + @"Only the first of two links sharing linkText 'Repeat' can be matched to the " + @"single drawn TOC occurrence; the second must be silently skipped"); + XCTAssertEqual([self totalLinkAnnotationsInDocument:document], (NSUInteger)1, + @"Exactly one annotation total: the skipped second link must not add anything"); + + PDFPage *tocPage = [document pageAtIndex:0]; + NSArray *tocAnnotations = [self linkAnnotationsOnPage:tocPage]; + XCTAssertEqual(tocAnnotations.count, (NSUInteger)1, + @"Exactly one annotation for 'Repeat' should land on the page holding its sole occurrence"); + if (tocAnnotations.count == 1 && [tocAnnotations.firstObject.action isKindOfClass:[PDFActionGoTo class]]) { + PDFActionGoTo *goTo = (PDFActionGoTo *)tocAnnotations.firstObject.action; + XCTAssertEqual([document indexForPage:goTo.destination.page], (NSUInteger)1, + @"The one resolved link must still land on the correct heading page"); + } +} + +@end diff --git a/plans/test_coverage_improvement_plan.md b/plans/test_coverage_improvement_plan.md index 8d286baa..17cff6d5 100644 --- a/plans/test_coverage_improvement_plan.md +++ b/plans/test_coverage_improvement_plan.md @@ -198,8 +198,9 @@ GitHub Actions macOS runners are: **Status:** ✅ **HTML EXPORT TESTS IMPLEMENTED** (Issue #30, Issue #234) - ✅ `MPHTMLExportTests.m` - Comprehensive HTML export testing (Issue #30) - ✅ `MPImageExportTests.m` - Base64 images, linked images, invalid URLs, alt text preservation (Issue #234) +- ✅ `MPPDFAnchorInjectorTests.m` - Headless PDFKit annotation-injection engine for clickable internal anchor links (TOC → heading) in exported PDFs, 8 tests (Issue #504) - ⏳ `MPExportTests.m` - General export operations (planned) -- ⏳ PDF export tests (planned) +- ⏳ General PDF generation tests (basic/images/styles - planned; anchor-link injection is covered above) **Actual Impact (MPHTMLExportTests.m):** - Tests added: Comprehensive suite covering word-breaking, long lines, special characters, Unicode, tables, and export.css integration @@ -439,6 +440,7 @@ MacDownTests/ │ ├── MPNotificationTests.m (✅ implemented - Issue #234 - notification observers) │ ├── MPHTMLExportTests.m (✅ implemented - Issue #30 - HTML export) │ ├── MPImageExportTests.m (✅ implemented - Issue #234 - image export) +│ ├── MPPDFAnchorInjectorTests.m (✅ implemented - Issue #504 - PDF anchor-link injection, 8 tests) │ └── MPExportTests.m (planned - general export operations) ├── Utilities/ (existing) │ ├── MPUtilityTests.m