diff --git a/next.config.ts b/next.config.ts index 013e81c7a..a4813cdab 100644 --- a/next.config.ts +++ b/next.config.ts @@ -309,6 +309,9 @@ const nextConfig: NextConfig = { "./src/lib/documents/pdf-child.mjs", "./src/lib/documents/pdf-engine.mjs", "./src/lib/documents/image-engine.mjs", + // The doctor-report PDF embeds its fonts, read from disk by a path the + // tracer cannot follow (`src/lib/doctor-report-pdf/fonts.ts`). + "./src/lib/doctor-report-pdf/fonts/**", // Document AI local text extraction and rasterization both run the // app's one pdfjs-dist (`src/lib/documents/pdfjs.ts`) server-side; the // text read is the provider-free fallback that keeps content search diff --git a/scripts/README.md b/scripts/README.md index 5692b78e4..18da03099 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -31,6 +31,7 @@ typecheck. - **`bench/`** — `seed-large-account.ts` (a synthetic ten-year account of about 1.26 million measurement rows) and `run.ts` (latency and SQL statements per request against it); see `docs/contributing/benchmarks.md`. Results land in `bench/results/`. - **`test-notifications.ts`** — exercise the notification dispatcher + reminder check. - **`fetch-geolite2.sh`** — download the MaxMind GeoLite2 MMDBs (needs a licence key) before `docker build`. +- **`build-pdf-fonts.py`** — rebuild the fonts the doctor-report PDF embeds (`src/lib/doctor-report-pdf/fonts/`) from the upstream Noto sources; needs `fonttools`. The source URLs are in its header. - **`install-hooks.sh`** — install the local git hooks. - **`generate-sw-version.mjs`**, **`print-bundle-report.mjs`** — service-worker version stamp and bundle-size report. - **`i18n/`** — locale tooling; **`__tests__/`** — tests for the scripts above. diff --git a/scripts/build-pdf-fonts.py b/scripts/build-pdf-fonts.py new file mode 100644 index 000000000..e6d2d5b2f --- /dev/null +++ b/scripts/build-pdf-fonts.py @@ -0,0 +1,191 @@ +#!/usr/bin/env python3 +"""Build the fonts the doctor-report PDF embeds. + +jsPDF draws one font per text run and has no glyph fallback, so every script +the report can carry has to live in one font file. No single upstream Noto +font covers what the report needs: Noto Sans has Latin, Greek and Cyrillic but +no arrows or comparison signs, Noto Sans KR has Hangul but no Latin +Extended-A (Polish). This script merges them: + + ReportSans-{Regular,Bold,Italic}.ttf + Noto Sans (Latin, Latin Extended-A/B, Greek, Cyrillic, punctuation, + super-/subscripts, currency) plus the arrows and mathematical operators + of Noto Sans Math. + ReportSansKR-{Regular,Bold}.ttf + The matching ReportSans weight plus Hangul (syllables, jamo, + compatibility jamo) and CJK punctuation from Noto Sans KR, instanced + at weight 400 / 700. Loaded only for a report that contains Hangul. + +All sources are SIL Open Font License 1.1; the licence texts sit beside the +output. The merged fonts carry their own family name, as the OFL asks of a +modified version. Layout tables (GSUB/GPOS/GDEF/MATH) are dropped: jsPDF +does no shaping, and precomposed Hangul syllables need none. + +Requires fonttools (`pip install fonttools`). Usage: + + python3 scripts/build-pdf-fonts.py + +where holds the unhinted upstream TTFs: + NotoSans-Regular.ttf, NotoSans-Bold.ttf, NotoSans-Italic.ttf + https://github.com/notofonts/notofonts.github.io/tree/main/fonts/NotoSans/unhinted/ttf + NotoSansMath-Regular.ttf + https://github.com/notofonts/notofonts.github.io/tree/main/fonts/NotoSansMath/unhinted/ttf + NotoSansKR[wght].ttf + https://github.com/google/fonts/tree/main/ofl/notosanskr +""" + +import copy +import sys +from pathlib import Path + +from fontTools import subset +from fontTools.ttLib import TTFont +from fontTools.varLib import instancer + + +def ranges(*spans): + out = set() + for lo, hi in spans: + out.update(range(lo, hi + 1)) + return out + + +BASE_CODEPOINTS = ranges( + (0x0020, 0x007E), # Basic Latin + (0x00A0, 0x024F), # Latin-1, Latin Extended-A/B + (0x0250, 0x02FF), # IPA, spacing modifiers + (0x0300, 0x036F), # combining diacritics + (0x0370, 0x03FF), # Greek + (0x0400, 0x052F), # Cyrillic + supplement + (0x1E00, 0x1EFF), # Latin Extended Additional + (0x2000, 0x206F), # general punctuation + (0x2070, 0x209F), # super-/subscripts + (0x20A0, 0x20CF), # currency + (0x2100, 0x214F), # letterlike (°C, №, ™) + (0x2150, 0x218F), # number forms +) +SYMBOL_CODEPOINTS = ranges( + (0x2190, 0x21FF), # arrows + (0x2200, 0x22FF), # mathematical operators (≈ ≤ ≥ ±) +) +HANGUL_CODEPOINTS = ranges( + (0x1100, 0x11FF), # Hangul jamo + (0x3000, 0x303F), # CJK symbols and punctuation + (0x3130, 0x318F), # compatibility jamo + (0xA960, 0xA97F), # jamo extended-A + (0xAC00, 0xD7A3), # syllables + (0xD7B0, 0xD7FF), # jamo extended-B +) +DROP_TABLES = ["GSUB", "GPOS", "GDEF", "MATH", "BASE", "JSTF", "DSIG", "STAT"] + + +def subset_font(font: TTFont, codepoints: set[int]) -> TTFont: + options = subset.Options() + options.layout_features = [] + options.drop_tables += DROP_TABLES + options.hinting = False + options.notdef_outline = True + options.name_IDs = ["*"] + options.glyph_names = True + subsetter = subset.Subsetter(options) + subsetter.populate(unicodes=codepoints & set(font.getBestCmap())) + subsetter.subset(font) + return font + + +def copy_glyph(base: TTFont, donor: TTFont, name: str, prefix: str) -> str: + """Copy one donor glyph (and its components) into base; return its name.""" + new_name = f"{prefix}{name}" + glyf = base["glyf"] + if new_name in glyf.glyphs: + return new_name + glyph = copy.deepcopy(donor["glyf"][name]) + if glyph.isComposite(): + for component in glyph.components: + component.glyphName = copy_glyph( + base, donor, component.glyphName, prefix + ) + glyf.glyphs[new_name] = glyph + glyf.glyphOrder.append(new_name) + base["hmtx"].metrics[new_name] = donor["hmtx"].metrics[name] + order = base.getGlyphOrder() + if new_name not in order: + order.append(new_name) + base.setGlyphOrder(order) + return new_name + + +def merge(base: TTFont, donor: TTFont, prefix: str) -> None: + if base["head"].unitsPerEm != donor["head"].unitsPerEm: + raise SystemExit("unitsPerEm differs between base and donor") + base_cmap = base.getBestCmap() + additions = {} + for cp, name in donor.getBestCmap().items(): + if cp in base_cmap: + continue + additions[cp] = copy_glyph(base, donor, name, prefix) + for table in base["cmap"].tables: + if table.isUnicode(): + if table.format == 4 and any(cp > 0xFFFF for cp in additions): + continue + table.cmap.update(additions) + base["maxp"].numGlyphs = len(base.getGlyphOrder()) + base["hhea"].numberOfHMetrics = len(base.getGlyphOrder()) + base["hhea"].advanceWidthMax = max( + base["hhea"].advanceWidthMax, + max(width for width, _ in donor["hmtx"].metrics.values()), + ) + + +def finish(font: TTFont, family: str, style: str, path: Path) -> None: + """Rename, drop glyph names, save. + + jsPDF copies the whole `post` table into every PDF it writes, subset or + not, so glyph names would cost each report tens of kilobytes (hundreds + with Hangul). Format 3 carries none. + """ + font["post"].formatType = 3.0 + rename(font, family, style) + font.save(path) + + +def rename(font: TTFont, family: str, style: str) -> None: + name = font["name"] + postscript = f"{family}-{style}" + for record in list(name.names): + if record.nameID in (1, 16): + record.string = family + elif record.nameID in (2, 17): + record.string = style + elif record.nameID == 3: + record.string = f"{postscript};HealthLog" + elif record.nameID == 4: + record.string = f"{family} {style}" + elif record.nameID == 6: + record.string = postscript + + +def build(src: Path, out: Path) -> None: + out.mkdir(parents=True, exist_ok=True) + math = subset_font(TTFont(src / "NotoSansMath-Regular.ttf"), SYMBOL_CODEPOINTS) + kr_source = src / "NotoSansKR[wght].ttf" + + for style in ("Regular", "Bold", "Italic"): + base = subset_font(TTFont(src / f"NotoSans-{style}.ttf"), BASE_CODEPOINTS) + merge(base, math, "math.") + finish(base, "ReportSans", style, out / f"ReportSans-{style}.ttf") + + if style == "Italic": + continue + weight = 400 if style == "Regular" else 700 + kr = subset_font(TTFont(kr_source), HANGUL_CODEPOINTS) + kr = instancer.instantiateVariableFont(kr, {"wght": weight}) + with_hangul = TTFont(out / f"ReportSans-{style}.ttf") + merge(with_hangul, kr, "kr.") + finish(with_hangul, "ReportSansKR", style, out / f"ReportSansKR-{style}.ttf") + + +if __name__ == "__main__": + if len(sys.argv) != 3: + raise SystemExit(__doc__) + build(Path(sys.argv[1]), Path(sys.argv[2])) diff --git a/src/lib/doctor-report-pdf/fonts/OFL.txt b/src/lib/doctor-report-pdf/fonts/OFL.txt new file mode 100644 index 000000000..9bbcbbf53 --- /dev/null +++ b/src/lib/doctor-report-pdf/fonts/OFL.txt @@ -0,0 +1,95 @@ +Copyright 2022 The Noto Project Authors (https://github.com/notofonts/latin-greek-cyrillic) +Copyright 2022 The Noto Project Authors (https://github.com/notofonts/math) +Copyright 2014-2021 Adobe (http://www.adobe.com/), with Reserved Font Name 'Source' + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/src/lib/doctor-report-pdf/fonts/ReportSans-Bold.ttf b/src/lib/doctor-report-pdf/fonts/ReportSans-Bold.ttf new file mode 100644 index 000000000..fd7041ade Binary files /dev/null and b/src/lib/doctor-report-pdf/fonts/ReportSans-Bold.ttf differ diff --git a/src/lib/doctor-report-pdf/fonts/ReportSans-Italic.ttf b/src/lib/doctor-report-pdf/fonts/ReportSans-Italic.ttf new file mode 100644 index 000000000..e6066aa1d Binary files /dev/null and b/src/lib/doctor-report-pdf/fonts/ReportSans-Italic.ttf differ diff --git a/src/lib/doctor-report-pdf/fonts/ReportSans-Regular.ttf b/src/lib/doctor-report-pdf/fonts/ReportSans-Regular.ttf new file mode 100644 index 000000000..d399a8ed9 Binary files /dev/null and b/src/lib/doctor-report-pdf/fonts/ReportSans-Regular.ttf differ diff --git a/src/lib/doctor-report-pdf/fonts/ReportSansKR-Bold.ttf b/src/lib/doctor-report-pdf/fonts/ReportSansKR-Bold.ttf new file mode 100644 index 000000000..1debef876 Binary files /dev/null and b/src/lib/doctor-report-pdf/fonts/ReportSansKR-Bold.ttf differ diff --git a/src/lib/doctor-report-pdf/fonts/ReportSansKR-Regular.ttf b/src/lib/doctor-report-pdf/fonts/ReportSansKR-Regular.ttf new file mode 100644 index 000000000..38fbf61db Binary files /dev/null and b/src/lib/doctor-report-pdf/fonts/ReportSansKR-Regular.ttf differ