Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions MacDown 3000.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down Expand Up @@ -709,6 +713,10 @@
UITEST000001SWIFTFILE /* MacDownUITests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MacDownUITests.swift; sourceTree = "<group>"; };
UITEST000002INFOPLIST /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
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 = "<group>"; };
504AC0000000000000000002 /* MPPDFAnchorInjector.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = MPPDFAnchorInjector.m; sourceTree = "<group>"; };
504AC0000000000000000004 /* MPPDFAnchorInjectorTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = MPPDFAnchorInjectorTests.m; sourceTree = "<group>"; };
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 */
Expand All @@ -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;
};
Expand All @@ -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;
};
Expand Down Expand Up @@ -867,6 +877,8 @@
1F0D9D91194AC852008E1856 /* MPDocument.h */,
1F0D9D92194AC852008E1856 /* MPDocument.m */,
1F0D9D83194AC83C008E1856 /* MPDocument.xib */,
504AC0000000000000000001 /* MPPDFAnchorInjector.h */,
504AC0000000000000000002 /* MPPDFAnchorInjector.m */,
);
name = Document;
path = Code/Document;
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -1119,6 +1132,7 @@
QLTST00002PREF00000M /* MPQuickLookPreferencesTests.m */,
QLTST00003PVCT00000M /* MPPreviewViewControllerTests.m */,
5E44CF1BD72E49A99DA6A4C1 /* MPPaneToggleTests.m */,
504AC0000000000000000004 /* MPPDFAnchorInjectorTests.m */,
);
path = MacDownTests;
sourceTree = "<group>";
Expand Down Expand Up @@ -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;
};
Expand Down Expand Up @@ -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;
};
Expand Down
207 changes: 207 additions & 0 deletions MacDown/Code/Document/MPDocument.m
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,46 @@
#import "MPHTMLResourceURLs.h"
#import "MPURLSecurityPolicy.h"
#import <JavaScriptCore/JavaScriptCore.h>
// Issue #504: PDF export post-processing (clickable internal anchor links).
#import <PDFKit/PDFKit.h>
#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)
{
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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<MPPDFAnchorLink *> **)outLinks
headings:(NSArray<MPPDFAnchorHeading *> **)outHeadings;
- (void)postProcessExportedPDFAtURL:(NSURL *)url;

@end

Expand Down Expand Up @@ -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)
Expand All @@ -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,
Expand All @@ -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<MPPDFAnchorLink *> **)outLinks
headings:(NSArray<MPPDFAnchorHeading *> **)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<MPPDFAnchorLink *> *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<MPPDFAnchorHeading *> *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<MPPDFAnchorLink *> *links = nil;
NSArray<MPPDFAnchorHeading *> *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];
Expand Down Expand Up @@ -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;
Expand Down
Loading
Loading