diff --git a/MacDown 3000.xcodeproj/project.pbxproj b/MacDown 3000.xcodeproj/project.pbxproj index 13a9e68b..81eec666 100644 --- a/MacDown 3000.xcodeproj/project.pbxproj +++ b/MacDown 3000.xcodeproj/project.pbxproj @@ -155,6 +155,7 @@ ISSUE313TOOLBARBUILDFILE /* MPToolbarControllerTests.m in Sources */ = {isa = PBXBuildFile; fileRef = ISSUE313TOOLBARFILEREF /* MPToolbarControllerTests.m */; }; ISSUE318STYLERELBUILDFILE /* MPDocumentStyleUpdateTests.m in Sources */ = {isa = PBXBuildFile; fileRef = ISSUE318STYLERELFILEREF /* MPDocumentStyleUpdateTests.m */; }; ISSUE325MJSCRLBUILDFILE /* MPMathJaxScrollTests.m in Sources */ = {isa = PBXBuildFile; fileRef = ISSUE325MJSCRLFILEREF /* MPMathJaxScrollTests.m */; }; + MJLOCALFONTSBUILDFILE /* MPMathJaxLocalFontsTests.m in Sources */ = {isa = PBXBuildFile; fileRef = MJLOCALFONTSFILEREF /* MPMathJaxLocalFontsTests.m */; }; ISSUE331MRMDRNDRBUILDFILE /* MPMermaidRenderingTests.m in Sources */ = {isa = PBXBuildFile; fileRef = ISSUE331MRMDRNDRFILEREF /* MPMermaidRenderingTests.m */; }; ISSUE332GVIZRNDRBUILDFILE /* MPGraphvizRenderingTests.m in Sources */ = {isa = PBXBuildFile; fileRef = ISSUE332GVIZRNDRFILEREF /* MPGraphvizRenderingTests.m */; }; ISSUE341IMGRENDERBUILDFILE /* MPImageRenderingTests.m in Sources */ = {isa = PBXBuildFile; fileRef = ISSUE341IMGRENDERFILEREF /* MPImageRenderingTests.m */; }; @@ -732,6 +733,7 @@ ISSUE313TOOLBARFILEREF /* MPToolbarControllerTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = MPToolbarControllerTests.m; sourceTree = ""; }; ISSUE318STYLERELFILEREF /* MPDocumentStyleUpdateTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = MPDocumentStyleUpdateTests.m; sourceTree = ""; }; ISSUE325MJSCRLFILEREF /* MPMathJaxScrollTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = MPMathJaxScrollTests.m; sourceTree = ""; }; + MJLOCALFONTSFILEREF /* MPMathJaxLocalFontsTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = MPMathJaxLocalFontsTests.m; sourceTree = ""; }; ISSUE331MRMDRNDRFILEREF /* MPMermaidRenderingTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = MPMermaidRenderingTests.m; sourceTree = ""; }; ISSUE332GVIZRNDRFILEREF /* MPGraphvizRenderingTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = MPGraphvizRenderingTests.m; sourceTree = ""; }; ISSUE341IMGRENDERFILEREF /* MPImageRenderingTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = MPImageRenderingTests.m; sourceTree = ""; }; @@ -1175,6 +1177,7 @@ 8552820CEE8D00E83AEE3897 /* MPHTMLResourceURLsTests.m */, ISSUE341IMGRENDERFILEREF /* MPImageRenderingTests.m */, ISSUE325MJSCRLFILEREF /* MPMathJaxScrollTests.m */, + MJLOCALFONTSFILEREF /* MPMathJaxLocalFontsTests.m */, 1FCDCA371944F97800B1F966 /* Supporting Files */, B3BD139B59E787FBB9F38228 /* MPEditorViewSubstitutionTests.m */, 66A67CD2B90882C7F91B6251 /* MPEditorViewPasteboardTests.m */, @@ -1929,6 +1932,7 @@ ISSUE278TABLEBUILDFILE /* MPInsertTableTests.m in Sources */, ISSUE543EXTCHGBUILDFILE /* MPExternalChangeReloadTests.m in Sources */, ISSUE325MJSCRLBUILDFILE /* MPMathJaxScrollTests.m in Sources */, + MJLOCALFONTSBUILDFILE /* MPMathJaxLocalFontsTests.m in Sources */, 5AD88BF0766B938F9D61A831 /* MPEditorViewSubstitutionTests.m in Sources */, C185BB0F201C78DF6D946E1F /* MPEditorViewPasteboardTests.m in Sources */, ISSUE313TOOLBARBUILDFILE /* MPToolbarControllerTests.m in Sources */, diff --git a/MacDown/Code/Document/MPDocument.m b/MacDown/Code/Document/MPDocument.m index f5d6adc9..0d55a0bd 100644 --- a/MacDown/Code/Document/MPDocument.m +++ b/MacDown/Code/Document/MPDocument.m @@ -1792,14 +1792,46 @@ - (BOOL)textViewShouldMoveToLeftEndOfLine:(NSTextView *)textView - (NSURLRequest *)webView:(WebView *)sender resource:(id)identifier willSendRequest:(NSURLRequest *)request redirectResponse:(NSURLResponse *)redirectResponse fromDataSource:(WebDataSource *)dataSource { - + // The MathJax loader is served from the bundle, carrying its "?config=…" + // query so MathJax still loads the right combined config. if ([[request.URL lastPathComponent] isEqualToString:@"MathJax.js"]) { NSURLComponents *origComps = [NSURLComponents componentsWithURL:[request URL] resolvingAgainstBaseURL:YES]; NSURLComponents *updatedComps = [NSURLComponents componentsWithURL:[[NSBundle mainBundle] URLForResource:@"MathJax" withExtension:@"js" subdirectory:@"MathJax"] resolvingAgainstBaseURL:NO]; [updatedComps setQueryItems:[origComps queryItems]]; - request = [NSURLRequest requestWithURL:[updatedComps URL]]; + return [NSURLRequest requestWithURL:[updatedComps URL]]; + } + + // The TeX HTML-CSS web fonts also ship in the bundle. Serve them locally too: + // without the fonts local, the HTML-CSS output jax waits on a render-time + // web-font fetch and, when it times out, falls back to bitmap image fonts + // that pixelate on zoom. Any MathJax CDN resource whose path maps to a file + // present in the bundle is redirected; the rest (config, jax.js, fontdata.js) + // fall through to the CDN unchanged, so this only ever short-circuits a load + // to a file that exists. The "?V=…" query is dropped — a file: URL with a + // query may not resolve, which would fail the load back into the fallback. + NSURL *cdn = [NSURL URLWithString:kMPMathJaxCDN]; + NSURL *url = request.URL; + if (cdn.host.length && [url.host isEqualToString:cdn.host]) + { + // Directory on the CDN that holds MathJax.js, e.g. + // "/ajax/libs/mathjax/2.7.3/". The trailing slash keeps a sibling + // version directory (…/2.7.30/) from matching this one's prefix. + NSString *prefix = [[cdn.path stringByDeletingLastPathComponent] + stringByAppendingString:@"/"]; + if ([url.path hasPrefix:prefix]) + { + NSString *subpath = [url.path substringFromIndex:prefix.length]; + NSURL *mathJaxDir = + [[NSBundle mainBundle].resourceURL URLByAppendingPathComponent:@"MathJax"]; + NSURL *local = [mathJaxDir URLByAppendingPathComponent:subpath]; + if (subpath.length && + [[NSFileManager defaultManager] fileExistsAtPath:local.path]) + { + request = [NSURLRequest requestWithURL:local]; + } + } } return request; diff --git a/MacDown/Code/Document/MPRenderer.h b/MacDown/Code/Document/MPRenderer.h index ab1bb39c..a56c6faa 100644 --- a/MacDown/Code/Document/MPRenderer.h +++ b/MacDown/Code/Document/MPRenderer.h @@ -10,6 +10,11 @@ @protocol MPRendererDataSource; @protocol MPRendererDelegate; +// The MathJax loader URL the preview requests. The bundle serves this and the +// TeX fonts locally (see -[MPDocument webView:resource:willSendRequest:…]), so +// MPDocument matches requests against this origin. +extern NSString * const kMPMathJaxCDN; + typedef NS_ENUM(NSUInteger, MPCodeBlockAccessoryType) { diff --git a/MacDown/Code/Document/MPRenderer.m b/MacDown/Code/Document/MPRenderer.m index de877e18..98c93fde 100644 --- a/MacDown/Code/Document/MPRenderer.m +++ b/MacDown/Code/Document/MPRenderer.m @@ -23,7 +23,7 @@ // of https://github.com/mathjax/MathJax/issues/548. If the fix has been merged // in to MathJax, then the WebResourceLoadDelegate can be removed from MPDocument // and MathJax.js can be removed from this project. -static NSString * const kMPMathJaxCDN = +NSString * const kMPMathJaxCDN = @"https://cdnjs.cloudflare.com/ajax/libs/mathjax/2.7.3/MathJax.js" @"?config=TeX-AMS-MML_HTMLorMML"; static NSString * const kMPPrismScriptDirectory = @"Prism/components"; @@ -486,6 +486,28 @@ NS_INLINE void MPFreeHTMLRenderer(hoedown_renderer *htmlRenderer) return escaped; } +/** + * Origin of kMPMathJaxCDN as a CSP source expression, e.g. + * "https://cdnjs.cloudflare.com". + * + * MathJax resolves its configuration, jax and fonts against the script + * element's own src, so every one of them comes from this origin. Deriving the + * source expression from the URL keeps the policy from drifting away from the + * URL actually loaded when the CDN changes. + */ +NS_INLINE NSString *MPMathJaxCDNOrigin(void) +{ + NSURLComponents *cdn = [NSURLComponents componentsWithString:kMPMathJaxCDN]; + NSCAssert(cdn.scheme.length && cdn.host.length, + @"kMPMathJaxCDN must carry a scheme and host"); + if (cdn.port) + { + return [NSString stringWithFormat:@"%@://%@:%@", + cdn.scheme, cdn.host, cdn.port]; + } + return [NSString stringWithFormat:@"%@://%@", cdn.scheme, cdn.host]; +} + NS_INLINE NSString *MPPreviewContentSecurityPolicy(void) { // MathJax 2.x relies on eval/new Function during startup, and bundled @@ -496,17 +518,24 @@ NS_INLINE void MPFreeHTMLRenderer(hoedown_renderer *htmlRenderer) // that source — the container still lays out, but the bits never // load. MPImageRenderingTests pins this contract; update it deliberately // if you tighten the policy. - return @"default-src 'none'; " - @"base-uri 'none'; " - @"form-action 'none'; " - @"object-src 'none'; " - @"frame-src 'none'; " - @"img-src data: file: http: https:; " - @"media-src data: file: http: https:; " - @"style-src 'self' 'unsafe-inline' file:; " - @"font-src data: file:; " - @"connect-src http: https:; " - @"script-src 'self' file: https://cdnjs.cloudflare.com 'unsafe-eval'"; + // + // font-src carries the MathJax origin for the same reason script-src does: + // the TeX faces are fetched from it, and blocking them makes the HTML-CSS + // output jax stall for its web-font timeout and then fall back to bitmap + // image fonts. + NSString *mathJax = MPMathJaxCDNOrigin(); + return [NSString stringWithFormat: + @"default-src 'none'; " + @"base-uri 'none'; " + @"form-action 'none'; " + @"object-src 'none'; " + @"frame-src 'none'; " + @"img-src data: file: http: https:; " + @"media-src data: file: http: https:; " + @"style-src 'self' 'unsafe-inline' file:; " + @"font-src data: file: %@; " + @"connect-src http: https:; " + @"script-src 'self' file: %@ 'unsafe-eval'", mathJax, mathJax]; } NS_INLINE NSString *MPPreviewHeadTags(NSString *checkboxBridgeToken) diff --git a/MacDown/Resources/MathJax/init.js b/MacDown/Resources/MathJax/init.js index 6b66412a..14f32e2e 100644 --- a/MacDown/Resources/MathJax/init.js +++ b/MacDown/Resources/MathJax/init.js @@ -2,7 +2,24 @@ MathJax.Hub.Config({ 'showProcessingMessages': false, - 'messageStyle': 'none' + 'messageStyle': 'none', + + // The HTML-CSS output jax prefers fonts installed on the machine over + // MathJax's own, and its default availableFonts list names STIX. macOS + // ships STIX 1.x as system-reserved faces that WebKit resolves by name, so + // the preview picks them up and typesets digits and operators in + // STIXGeneral-Regular — a Times-metric face indistinguishable from body + // text. Browsers that do not enumerate those faces, such as Firefox, load + // MathJax's TeX fonts instead and render the same document correctly. + // + // Emptying the list makes the preview do the same. preferredFont is nulled + // alongside it because MathJax tests the preferred font even when it is + // absent from availableFonts, so the list alone would still take the local + // path on a machine carrying MathJax's own TeX fonts. + 'HTML-CSS': { + 'availableFonts': [], + 'preferredFont': null + } }); if (typeof MathJaxListener !== 'undefined') { diff --git a/MacDownTests/MPMathJaxLocalFontsTests.m b/MacDownTests/MPMathJaxLocalFontsTests.m new file mode 100644 index 00000000..ca2f5ea2 --- /dev/null +++ b/MacDownTests/MPMathJaxLocalFontsTests.m @@ -0,0 +1,150 @@ +// +// MPMathJaxLocalFontsTests.m +// MacDown 3000 +// +// MathJax's HTML-CSS output jax renders in vector web fonts, but when those +// fonts are fetched from the CDN at render time and the fetch times out it +// falls back to bitmap image fonts that pixelate on zoom. The app bundles the +// TeX woff fonts and the resource-load delegate serves them (and the MathJax +// loader) from the bundle. These tests pin that redirect and the bundled set. +// + +#import +#import +#import "MPDocument.h" + +// The WebResourceLoadDelegate method under test (implemented in MPDocument.m). +@interface MPDocument (LocalFontsTesting) +- (NSURLRequest *)webView:(WebView *)sender resource:(id)identifier + willSendRequest:(NSURLRequest *)request + redirectResponse:(NSURLResponse *)redirectResponse + fromDataSource:(WebDataSource *)dataSource; +@end + +// Must match kMPMathJaxCDN's origin and version directory in MPRenderer.m. +static NSString * const kMPTestCDNBase = + @"https://cdnjs.cloudflare.com/ajax/libs/mathjax/2.7.3/"; + + +@interface MPMathJaxLocalFontsTests : XCTestCase +@property (strong) MPDocument *document; +@end + + +@implementation MPMathJaxLocalFontsTests + +- (void)setUp +{ + [super setUp]; + self.document = [[MPDocument alloc] init]; +} + +- (void)tearDown +{ + self.document = nil; + [super tearDown]; +} + +/// Runs a CDN request (base + suffix) through the real delegate and returns the +/// URL it decides to load. +- (NSURL *)resolvedURLForSuffix:(NSString *)suffix +{ + NSURL *url = [NSURL URLWithString: + [kMPTestCDNBase stringByAppendingString:suffix]]; + NSURLRequest *req = [NSURLRequest requestWithURL:url]; + NSURLRequest *out = [self.document webView:nil resource:nil + willSendRequest:req redirectResponse:nil + fromDataSource:nil]; + return out.URL; +} + +- (void)testWoffFontRedirectsToBundleWithoutQuery +{ + NSURL *result = [self resolvedURLForSuffix: + @"fonts/HTML-CSS/TeX/woff/MathJax_Main-Regular.woff?V=2.7.3"]; + + XCTAssertTrue(result.isFileURL, + @"A bundled woff font should be served from the bundle"); + XCTAssertNil(result.query, + @"The local font URL must carry no query, or file: may not " + @"resolve and the load falls back to image fonts"); + XCTAssertTrue([result.path hasSuffix: + @"/MathJax/fonts/HTML-CSS/TeX/woff/MathJax_Main-Regular.woff"], + @"Should map to the bundled font path, got %@", result.path); + XCTAssertTrue([[NSFileManager defaultManager] fileExistsAtPath:result.path], + @"The redirected font must actually exist in the bundle"); +} + +- (void)testUnbundledResourcePassesThroughToCDN +{ + // jax.js is not bundled, so the delegate must leave the request on the CDN. + NSURL *result = [self resolvedURLForSuffix: + @"jax/output/HTML-CSS/jax.js?V=2.7.3"]; + + XCTAssertFalse(result.isFileURL, + @"An un-bundled resource must not be redirected locally"); + XCTAssertEqualObjects(result.host, @"cdnjs.cloudflare.com"); + XCTAssertTrue([result.path hasSuffix:@"jax/output/HTML-CSS/jax.js"]); +} + +- (void)testMathJaxLoaderStillRedirectsWithConfigQuery +{ + // Regression guard for the behaviour the generalised redirect replaces. + NSURL *result = [self resolvedURLForSuffix: + @"MathJax.js?config=TeX-AMS-MML_HTMLorMML"]; + + XCTAssertTrue(result.isFileURL, + @"MathJax.js should be served from the bundle"); + XCTAssertTrue([result.path hasSuffix:@"/MathJax/MathJax.js"], + @"Should map to the bundled loader, got %@", result.path); + XCTAssertEqualObjects(result.query, @"config=TeX-AMS-MML_HTMLorMML", + @"MathJax.js must keep its config query"); +} + +- (void)testFullTeXWoffSetIsBundled +{ + // Authoritative list of the MathJax 2.7.3 HTML-CSS TeX woff files, copied + // from the upstream distribution. It is intentionally NOT enumerated from + // the bundle's own contents: a list read back from what was dropped could + // never fail on an incomplete drop. A missing family (e.g. AMS) would let a + // document's glyphs fetch remotely and pixelate on a timeout. + NSArray *expected = @[ + @"MathJax_AMS-Regular", + @"MathJax_Caligraphic-Bold", + @"MathJax_Caligraphic-Regular", + @"MathJax_Fraktur-Bold", + @"MathJax_Fraktur-Regular", + @"MathJax_Main-Bold", + @"MathJax_Main-Italic", + @"MathJax_Main-Regular", + @"MathJax_Math-BoldItalic", + @"MathJax_Math-Italic", + @"MathJax_Math-Regular", + @"MathJax_SansSerif-Bold", + @"MathJax_SansSerif-Italic", + @"MathJax_SansSerif-Regular", + @"MathJax_Script-Regular", + @"MathJax_Size1-Regular", + @"MathJax_Size2-Regular", + @"MathJax_Size3-Regular", + @"MathJax_Size4-Regular", + @"MathJax_Typewriter-Regular", + @"MathJax_Vector-Bold", + @"MathJax_Vector-Regular", + ]; + + NSURL *woffDir = [[NSBundle mainBundle].resourceURL + URLByAppendingPathComponent:@"MathJax/fonts/HTML-CSS/TeX/woff"]; + + for (NSString *name in expected) + { + NSURL *font = [woffDir URLByAppendingPathComponent: + [name stringByAppendingPathExtension:@"woff"]]; + XCTAssertTrue([[NSFileManager defaultManager] fileExistsAtPath:font.path], + @"missing bundled TeX font: %@.woff", name); + } + XCTAssertEqual(expected.count, 22u, + @"The 2.7.3 TeX woff set has 22 files"); +} + +@end diff --git a/MacDownTests/MPMathJaxRenderingTests.m b/MacDownTests/MPMathJaxRenderingTests.m index fada7230..529a7772 100644 --- a/MacDownTests/MPMathJaxRenderingTests.m +++ b/MacDownTests/MPMathJaxRenderingTests.m @@ -261,4 +261,77 @@ - (void)testMathWithSpecialCharacters XCTAssertTrue([html length] > 0, @"HTML should not be empty"); } + +#pragma mark - Font Selection Tests + +/** + * The HTML-CSS output jax prefers fonts installed on the machine over + * MathJax's own, and macOS ships STIX faces that WebKit resolves by name. + * Declining them keeps the preview on MathJax's TeX faces; otherwise it + * typesets digits and operators in STIXGeneral-Regular, a Times-metric face + * indistinguishable from body text. + * + * Both settings are matched by value, and matched inside the 'HTML-CSS' + * block. Prose naming either setting appears in the same embedded script and + * must not satisfy the assertion, and a setting hoisted out of the block + * reaches no output jax at all: MathJax merges configuration per jax id, so + * availableFonts at the top level of MathJax.Hub.Config is silently ignored + * and the STIX regression returns. + */ +- (void)testPreviewDeclinesLocallyInstalledMathFonts +{ + self.dataSource.markdown = @"Inline \\( x = 2y + 15 \\)"; + [self.renderer parseMarkdown:self.dataSource.markdown]; + [self.renderer render]; + + NSString *html = self.delegate.lastHTML; + XCTAssertNotNil(html, @"Preview render should produce HTML"); + + // [^}] spans newlines, so this stays within the 'HTML-CSS' object. + NSString *inBlock = @"['\"]?HTML-CSS['\"]?\\s*:\\s*\\{[^}]*"; + NSUInteger (^matchCount)(NSString *) = ^(NSString *setting) { + NSRegularExpression *re = + [NSRegularExpression regularExpressionWithPattern: + [inBlock stringByAppendingString:setting] + options:0 error:NULL]; + return [re numberOfMatchesInString:html options:0 + range:NSMakeRange(0, html.length)]; + }; + + XCTAssertEqual(matchCount(@"['\"]?availableFonts['\"]?\\s*:\\s*\\[\\s*\\]"), + (NSUInteger)1, + @"HTML-CSS config should set availableFonts to an empty " + @"list, so locally installed STIX faces do not win over " + @"MathJax's TeX faces"); + XCTAssertEqual(matchCount(@"['\"]?preferredFont['\"]?\\s*:\\s*null"), + (NSUInteger)1, + @"HTML-CSS config should null preferredFont: MathJax tests " + @"the preferred font even when it is absent from " + @"availableFonts, so an empty list alone still takes the " + @"local path on a machine with the TeX fonts installed"); +} + +/** + * MathJax serves its TeX faces from the same CDN that serves its scripts. + * With font-src blocking that origin the output jax stalls for its web-font + * timeout and then falls back to bitmap image fonts. + * + * The origin is spelled out rather than derived from kMPMathJaxCDN, which is + * file-static and unreachable from here. Since the policy derives both + * directives from that constant, pointing it at another host fails this + * assertion rather than silently passing while the policy moves. + */ +- (void)testPreviewCSPAllowsMathJaxFontsFromCDN +{ + self.dataSource.markdown = @"Inline \\( x = 2y + 15 \\)"; + [self.renderer parseMarkdown:self.dataSource.markdown]; + [self.renderer render]; + + NSString *html = self.delegate.lastHTML; + XCTAssertNotNil(html, @"Preview render should produce HTML"); + XCTAssertTrue( + [html containsString:@"font-src data: file: https://cdnjs.cloudflare.com;"], + @"CSP font-src should allow the CDN serving MathJax's TeX faces"); +} + @end diff --git a/plans/mathjax-local-fonts.md b/plans/mathjax-local-fonts.md new file mode 100644 index 00000000..6e3b7109 --- /dev/null +++ b/plans/mathjax-local-fonts.md @@ -0,0 +1,150 @@ +# Fix: keep MathJax's TeX fonts local (stop the raster fallback) + +## Problem (root cause, confirmed by instrumentation) + +Preview math renders as **bitmap image fonts** — pixelated on zoom — instead of +crisp vector text. Commit `472e2a1` set the HTML-CSS output jax's +`availableFonts: []` / `preferredFont: null` (init.js) to stop it typesetting in +macOS's system STIX faces, forcing MathJax's own **TeX web fonts**. But the app +ships *no* fonts: `Resources/MathJax/` holds only the 63 KB `MathJax.js` loader +and `init.js`. So the TeX woff fonts are fetched from the CDN at render time, and +when they don't arrive in time (web-font timeout, cold cache, slow/offline link) +HTML-CSS falls back to bitmap image fonts. + +A resource-load probe on a live render confirmed it. Of the requests the preview +issues, `MathJax.js` is already served from the bundle (the existing redirect in +`-webView:resource:willSendRequest:…`, `MPDocument.m:1796`), while these come from +`https://cdnjs.cloudflare.com/ajax/libs/mathjax/2.7.3/…` at runtime: + +``` +config/TeX-AMS-MML_HTMLorMML.js +jax/output/HTML-CSS/jax.js +jax/output/HTML-CSS/fonts/TeX/fontdata.js +fonts/HTML-CSS/TeX/woff/MathJax_Main-Regular.woff ← the fonts +fonts/HTML-CSS/TeX/woff/MathJax_Math-Italic.woff (fetched on demand, +fonts/HTML-CSS/TeX/woff/MathJax_Size1-Regular.woff per glyphs used) +fonts/HTML-CSS/TeX/woff/MathJax_Size2-Regular.woff +``` + +Two facts from the probe drive the fix: **the `.woff` requests pass through the +resource-load delegate** (so they can be redirected exactly as `MathJax.js` is), +and **they target the CDN** (so keeping them local removes the render-time +dependency). + +## Requirement (outcome) + +- **F1.** Wherever preview math renders, it renders in **vector** TeX faces at any + zoom — no bitmap image-font fallback — including the very first render on a cold + cache, while staying HTML-CSS output so the math remains selectable, searchable + and copy-pasteable (SVG/image output is explicitly rejected for that reason). + Local woff fonts load synchronously from `file:`, so the HTML-CSS web-font + timeout that triggers the image-font fallback never fires. + +Non-goals: **fully offline math.** The `config`, output `jax.js` and `fontdata.js` +still load from the CDN (and are cached), so with no network the math does not +render at all — unchanged from today, and not what was reported. This fix keeps +the *fonts* local, per the request; bundling the remaining MathJax resources for +true offline rendering is a separate, larger change. Also out of scope: changing +the output jax away from HTML-CSS. + +Why fonts-only still fixes the reported bug: the image-font fallback is a property +of the woff `@font-face` load, which local fonts make instant; it only occurs +after `fontdata.js` has loaded, i.e. exactly when math renders. So local fonts +remove the fallback in every case where math renders, cold cache included. + +## Design + +### 1. Bundle the TeX woff fonts + +Add the complete MathJax 2.7.3 HTML-CSS **TeX woff** font set to +`MacDown/Resources/MathJax/fonts/HTML-CSS/TeX/woff/` (the on-demand fetch only +pulls the glyphs a document uses, so the *whole* set must be bundled for any +document to be covered). `Resources/MathJax` is a **folder reference** +(`project.pbxproj:356`, `lastKnownFileType = folder`), so the files are bundled +with no project edit. woff is what modern WebKit uses for `@font-face`; the otf +set is not bundled unless testing shows WebKit1 needs it. + +### 2. Serve bundled MathJax resources locally (generalise the redirect) + +Replace the `MathJax.js`-only special case in +`-webView:resource:willSendRequest:…` with a general rule: for a request to the +MathJax CDN, map its path below `…/mathjax//` to +`Resources/MathJax/`; if that file exists in the bundle, redirect the +request to the local `file:` URL; otherwise pass the request through to the CDN +unchanged. + +- This subsumes today's `MathJax.js` redirect (still bundled → still local) and + adds the woff fonts (now bundled → local), while `config`, `jax.js` and + `fontdata.js` (not bundled) keep going to the CDN and stay cached — behaviour + for those is unchanged. +- The mapping keys off the URL **path**. The local `file:` URL is built from the + path only — the `?V=2.7.3` query is dropped, since a `file:///…woff?V=2.7.3` + may not resolve and would fail the load back into the image fallback. Query + preservation stays limited to `MathJax.js` (which needs `?config=…`), exactly + as today. +- Fonts loaded from `file:` are already permitted: `font-src` includes `file:` + (`MPPreviewContentSecurityPolicy`). The CDN entry in `font-src`/`script-src` + stays as a fallback for the still-remote resources. + +### 3. Form as implemented + +The `MathJax.js` redirect is kept **verbatim** (early return, `URLForResource` + +`setQueryItems`) and the font redirect is added as a second branch. This was +chosen over folding `MathJax.js` into the general rule because the general +path-mapping builds the local URL with `resourceURL/URLByAppendingPathComponent`, +and re-attaching `MathJax.js`'s `?config=` query to *that* `file:` URL via +`NSURLComponents` yielded a non-`file:` URL — the loader would not load from the +bundle. The original `URLForResource`-based construction re-attaches the query +correctly and is already proven in production, so the working path is left +untouched. The two branches share nothing but intent; the font branch carries no +query, so there is no query-handling duplication to drift. +- **Point MathJax's font path at the bundle via init.js config:** more fragile — + it depends on MathJax's root/path resolution, which the redirect already + side-steps. The redirect is the mechanism the app already trusts for `MathJax.js`. + +## Tests + +- **`MPMathJaxLocalFontsTests`** (new). Every redirect assertion calls the real + delegate method — `[doc webView:nil resource:nil willSendRequest:req + redirectResponse:nil fromDataSource:nil]` — never a reimplemented mapping, so a + test fails if the actual delegate logic changes. + - A CDN woff URL + (`…/2.7.3/fonts/HTML-CSS/TeX/woff/MathJax_Main-Regular.woff?V=2.7.3`) is + rewritten to the bundled `file:` URL, that URL carries **no query**, and the + bundled file exists on disk. + - A CDN resource that is **not** bundled (e.g. `…/jax/output/HTML-CSS/jax.js`) + passes through unchanged (the returned request's URL is still the CDN URL). + - `MathJax.js` still redirects to the bundle with `?config=…` preserved + (regression guard for the existing behaviour the generalisation replaces). + - Every file in an **authoritative, hard-coded list** of the MathJax 2.7.3 + HTML-CSS TeX woff filenames is present in the bundle. The list is a fixed copy + of the real 2.7.3 `fonts/HTML-CSS/TeX/woff/` directory checked into the test — + **not** enumerated from the bundle's own contents, which could never fail on an + incomplete drop. Guards the failure mode where a missing family (e.g. omitting + `MathJax_AMS-Regular.woff`, so `$$\mathbb{R}\subseteq\mathbb{C}$$` still fetches + AMS remotely and pixelates) slips through. +- A live-WebView font-mode assertion (image vs web fonts) is not attempted here: + it needs a windowed WebView and network, and the existing MathJax tests are + string/JS-level. The redirect + presence tests are the guard; manual + verification (below) covers the rendered result. + +Manual: open a math document offline (or with the CDN blocked) on a cold cache — +math renders vector, not pixelated, and stays selectable. + +## Risk + +- **Font set completeness.** Missing a woff family leaves some glyphs fetching + remotely (and pixelating). The presence test enumerates the expected set. +- **WebKit1 woff support.** The probe shows WebKit1 already requests woff for + MathJax; serving the same bytes from `file:` is a strictly smaller change than + the remote fetch it replaces. If WebKit1 turns out to need otf, bundle otf too. +- **Redirect generality.** The new rule only ever redirects to a file that exists + in the bundle; anything else is passed through, so it cannot break loads for + un-bundled resources. +- **Version coupling.** The rule maps paths under `…/mathjax//`, and the + bundled woff are 2.7.3 bytes. Bumping `kMPMathJaxCDN` without refreshing the + bundled fonts would map a new version's font path to stale local bytes. This is + the pre-existing hazard already noted at `MPRenderer.m:22` (the bundled + `MathJax.js` has the same coupling); the fix widens it from one file to the font + set. Mitigation: the authoritative-list presence test is version-specific, so a + version bump that moves paths surfaces as a test to update.