documonster 0.15.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/README.md +1 -1
  2. package/README_zh.md +1 -1
  3. package/dist/esm/modules/draw/index.js +20 -0
  4. package/dist/esm/modules/draw/raster/canvas.js +285 -21
  5. package/dist/esm/modules/draw/raster/glyph-outline.js +357 -0
  6. package/dist/esm/modules/draw/raster/glyph-rasterizer.js +10 -506
  7. package/dist/esm/modules/draw/raster/surface.js +6 -1
  8. package/dist/esm/modules/draw/raster/system-raster-font.browser.js +28 -10
  9. package/dist/esm/modules/draw/raster/system-raster-font.js +374 -35
  10. package/dist/esm/modules/pdf/builder/document-builder.js +7 -4
  11. package/dist/esm/modules/pdf/builder/pdf-editor.js +8 -2
  12. package/dist/esm/modules/pdf/font/font-embedder.js +27 -85
  13. package/dist/esm/modules/pdf/font/font-manager.js +230 -9
  14. package/dist/esm/modules/pdf/font/font-plan.js +5 -3
  15. package/dist/esm/modules/pdf/font/system-fonts.js +29 -1190
  16. package/dist/esm/modules/pdf/font/text-features.js +29 -78
  17. package/dist/esm/modules/pdf/font/ttf-parser.js +38 -708
  18. package/dist/esm/modules/pdf/render/pdf-exporter.js +1 -1
  19. package/dist/esm/modules/pdf/word-bridge.js +1 -1
  20. package/dist/esm/modules/word/convert/markdown/markdown-import.js +42 -2
  21. package/dist/esm/modules/word/font/font-embed.js +74 -212
  22. package/dist/esm/modules/word/font/text-shaping.js +9 -630
  23. package/dist/esm/utils/cjk.js +28 -1
  24. package/dist/esm/utils/complex-text.js +245 -0
  25. package/dist/esm/utils/errors.js +19 -0
  26. package/dist/esm/{modules/pdf/font/system-fonts.browser.js → utils/font-discovery.browser.js} +7 -6
  27. package/dist/esm/utils/font-discovery.js +1264 -0
  28. package/dist/esm/utils/font-metrics.js +38 -0
  29. package/dist/esm/utils/font-ttf.js +894 -0
  30. package/dist/esm/utils/text-measure.js +20 -0
  31. package/dist/esm/utils/text-shaping.js +711 -0
  32. package/dist/iife/documonster.archive.iife.min.js +1 -1
  33. package/dist/iife/documonster.csv.iife.min.js +1 -1
  34. package/dist/iife/documonster.draw.iife.min.js +2 -2
  35. package/dist/iife/documonster.excel.iife.min.js +50 -50
  36. package/dist/iife/documonster.formula.iife.min.js +1 -1
  37. package/dist/iife/documonster.markdown.iife.min.js +1 -1
  38. package/dist/iife/documonster.mermaid.iife.min.js +3 -3
  39. package/dist/iife/documonster.pdf.iife.min.js +33 -33
  40. package/dist/iife/documonster.stream.iife.min.js +1 -1
  41. package/dist/iife/documonster.word.iife.min.js +60 -60
  42. package/dist/iife/documonster.xml.iife.min.js +1 -1
  43. package/dist/types/modules/draw/index.d.ts +36 -3
  44. package/dist/types/modules/draw/raster/canvas.d.ts +84 -5
  45. package/dist/types/modules/draw/raster/glyph-outline.d.ts +131 -0
  46. package/dist/types/modules/draw/raster/glyph-rasterizer.d.ts +10 -42
  47. package/dist/types/modules/draw/raster/surface.d.ts +68 -1
  48. package/dist/types/modules/draw/raster/system-raster-font.browser.d.ts +26 -10
  49. package/dist/types/modules/draw/raster/system-raster-font.d.ts +60 -14
  50. package/dist/types/modules/draw/types.d.ts +13 -3
  51. package/dist/types/modules/pdf/builder/document-builder.d.ts +5 -2
  52. package/dist/types/modules/pdf/builder/pdf-editor.d.ts +7 -1
  53. package/dist/types/modules/pdf/font/font-embedder.d.ts +19 -0
  54. package/dist/types/modules/pdf/font/font-manager.d.ts +75 -0
  55. package/dist/types/modules/pdf/font/font-plan.d.ts +5 -3
  56. package/dist/types/modules/pdf/font/system-fonts.d.ts +25 -155
  57. package/dist/types/modules/pdf/font/text-features.d.ts +25 -17
  58. package/dist/types/modules/pdf/font/ttf-parser.d.ts +24 -89
  59. package/dist/types/modules/pdf/types.d.ts +15 -0
  60. package/dist/types/modules/word/font/text-shaping.d.ts +10 -53
  61. package/dist/types/utils/cjk.d.ts +22 -0
  62. package/dist/types/utils/complex-text.d.ts +145 -0
  63. package/dist/types/utils/errors.d.ts +16 -0
  64. package/dist/types/{modules/pdf/font/system-fonts.browser.d.ts → utils/font-discovery.browser.d.ts} +22 -8
  65. package/dist/types/utils/font-discovery.d.ts +237 -0
  66. package/dist/types/utils/font-metrics.d.ts +29 -0
  67. package/dist/types/utils/font-ttf.d.ts +191 -0
  68. package/dist/types/utils/text-shaping.d.ts +111 -0
  69. package/package.json +1 -1
package/README.md CHANGED
@@ -211,7 +211,7 @@ const buffer = await Workbook.toBuffer(wb);
211
211
 
212
212
  ```html
213
213
  <!-- Script tag (no bundler) — one IIFE per module, each under the shared `Documonster` global -->
214
- <script src="https://unpkg.com/documonster@0.15.0/dist/iife/documonster.excel.iife.min.js"></script>
214
+ <script src="https://unpkg.com/documonster@0.16.0/dist/iife/documonster.excel.iife.min.js"></script>
215
215
  <script>
216
216
  const { Workbook, Cell } = Documonster.Excel;
217
217
  const wb = Workbook.create();
package/README_zh.md CHANGED
@@ -198,7 +198,7 @@ const buffer = await Workbook.toBuffer(wb);
198
198
 
199
199
  ```html
200
200
  <!-- Script 标签(无需打包工具)— 每个模块一个 IIFE,共享同一个 `Documonster` 全局 -->
201
- <script src="https://unpkg.com/documonster@0.15.0/dist/iife/documonster.excel.iife.min.js"></script>
201
+ <script src="https://unpkg.com/documonster@0.16.0/dist/iife/documonster.excel.iife.min.js"></script>
202
202
  <script>
203
203
  const { Workbook, Cell } = Documonster.Excel;
204
204
  const wb = Workbook.create();
@@ -40,5 +40,25 @@ export { POINTS_PER_PIXEL, measureText, widestText, wrapText } from "./text.js";
40
40
  export { renderDrawList, renderNode } from "./render.js";
41
41
  export { SvgSurface, toSvg } from "./svg.js";
42
42
  export { createRasterSurface, downsample, normalizeSamples, rasterizeToRgba } from "./raster/surface.js";
43
+ /**
44
+ * Ask, before rendering, whether text can be laid out one glyph per code point.
45
+ *
46
+ * `RasterizedImage.textWarnings` reports this after the fact; these answer beforehand,
47
+ * so a caller can route Arabic or Devanagari to `toSvg` — which is correct for them —
48
+ * instead of producing pixels it will have to discard.
49
+ */
50
+ export { isSimpleText, textFeaturesOf } from "../../utils/complex-text.js";
43
51
  export { BasicRasterCanvas } from "./raster/canvas.js";
52
+ /**
53
+ * Parse font bytes once, to hand the result to several renders.
54
+ *
55
+ * `RasterizeOptions.fonts` accepts bytes directly, but re-parses them on every call —
56
+ * for a CJK face that means rebuilding a 43,000-entry `cmap` each time, and a fresh
57
+ * object also misses the glyph cache, which is keyed on outline identity. It is also
58
+ * the only way to choose a face inside a `.ttc`.
59
+ *
60
+ * `fontHasGlyph` answers coverage for any `RasterFont`, including one a consumer
61
+ * implemented themselves without the optional `hasGlyph` member.
62
+ */
63
+ export { fontHasGlyph, parseRasterFont } from "./raster/glyph-outline.js";
44
64
  export { DEFAULT_TEXT_FAMILY, IDENTITY, apply, arcToCubics, multiply, rotate, flattenPath, rectNode, roundedRectToPath, rotationOf, scale, sectorToPath, translate, uniformScale } from "./types.js";
@@ -12,12 +12,48 @@
12
12
  * inside `setPixel` so every primitive — including glyph coverage — is bounded by
13
13
  * it without each one needing its own check.
14
14
  */
15
+ import { fontHasGlyph, parseInjectedFonts } from "./glyph-outline.js";
15
16
  import { rasterizeGlyph } from "./glyph-rasterizer.js";
16
17
  import { STROKE_FONT } from "./stroke-font.js";
17
- import { loadSystemFont } from "#platform/modules/draw/raster/system-raster-font";
18
+ import { resolveFontChain } from "#platform/modules/draw/raster/system-raster-font";
18
19
  import { measureText } from "../text.js";
19
20
  import { DEFAULT_TEXT_FAMILY } from "../types.js";
21
+ import { TextFeatureTally, isSimpleText, isWellMeasuredText } from "../../../utils/complex-text.js";
22
+ import { isFullWidthCodePoint, isNonPrintingControl, isZeroWidthCodePoint } from "../../../utils/font-metrics.js";
20
23
  import { parseCssColor } from "../../../utils/svg-lex.js";
24
+ import { shapeTextForFace } from "../../../utils/text-shaping.js";
25
+ /**
26
+ * Tab: the one control character that is width without ink.
27
+ *
28
+ * It has no glyph in practically any font, so it is neither drawn nor reported as
29
+ * undrawable — but it does advance the pen, by the same half em `@utils/text-measure`
30
+ * charges it. Treating it as a formatting control would collapse it to nothing and
31
+ * pull the rest of the line left; treating it as an ordinary missing glyph would draw
32
+ * `?` and tell the caller to install a font.
33
+ */
34
+ const TAB = 0x09;
35
+ /** Tab's advance, in em. Matches the default advance the measurer uses for it. */
36
+ const TAB_EM_WIDTH = 0.5;
37
+ /**
38
+ * Shape `text`, taking a contextual form only when some face in `fonts` can draw it.
39
+ *
40
+ * Substituting unconditionally is what a viewer does, and it is wrong here for the same
41
+ * reason it is wrong in the PDF writer: many faces publish the base letters and none of the
42
+ * presentation forms. Measured on one macOS host, eleven of the fifty Arabic-capable system
43
+ * faces are like that.
44
+ *
45
+ * And the failure was worse here than a `.notdef` box. This rasteriser has no notdef to
46
+ * fall back on, so a form nothing could draw painted **nothing at all** — `مرحبا` came out
47
+ * as an empty line — and the uncovered-code-point report then asked the caller to install a
48
+ * font for U+FE8E, which is not a thing anyone has. Falling back to the base letters gives
49
+ * up the joining and keeps the word.
50
+ */
51
+ function shapeAgainstChain(text, fonts) {
52
+ const drawable = (codePoint) => fonts.some(font => fontHasGlyph(font, codePoint));
53
+ return shapeTextForFace(text, drawable)
54
+ .map(cluster => cluster.visual)
55
+ .join("");
56
+ }
21
57
  /**
22
58
  * Cache rasterised glyphs so repeated text at one size is rasterised once. Keyed
23
59
  * by the outline reference (stable per font and code point) and the font size.
@@ -73,6 +109,37 @@ export class BasicRasterCanvas {
73
109
  * per-primitive bounds check would miss.
74
110
  */
75
111
  this.clips = [];
112
+ /**
113
+ * Every code point this canvas could not draw in any available face, accumulated
114
+ * across all {@link drawText} calls and never cleared.
115
+ *
116
+ * Accumulated rather than per-call because the useful question is about the
117
+ * finished picture, not the last label in it: `rasterizeToRgba` builds one canvas
118
+ * per render and reports this as `RgbaImage.uncoveredCodePoints`.
119
+ *
120
+ * A missing glyph is otherwise invisible — the pen advances and nothing is painted,
121
+ * which is exactly how a page of Chinese came out blank with no error raised. This
122
+ * lets a caller act on the gap (supply a font) instead of shipping a picture with
123
+ * holes in it.
124
+ */
125
+ this.uncoveredCodePoints = new Set();
126
+ /**
127
+ * Scripts and directionality this canvas cannot lay out, accumulated per canvas.
128
+ *
129
+ * A missing glyph is one kind of wrong output; text that needs shaping is another,
130
+ * and a worse one, because every glyph *is* drawn — just in the wrong shape or the
131
+ * wrong order. Arabic comes out as disconnected isolated letters, Devanagari with
132
+ * its vowel signs on the wrong side of the consonant. Nothing about the image looks
133
+ * broken to a reader who cannot read the script.
134
+ *
135
+ * `rasterizeToRgba` surfaces this as `RasterizedImage.textWarnings`. The detection
136
+ * is shared with the PDF writer, which has the same limitation for the same reason
137
+ * — see `@utils/complex-text`.
138
+ */
139
+ this.textFeatures = new TextFeatureTally();
140
+ /** Faces this canvas draws with, ahead of anything discovered on the host. */
141
+ this.ownFonts = [];
142
+ this.ownUseSystemFonts = true;
76
143
  this.data = new Uint8Array(width * height * 4);
77
144
  }
78
145
  /**
@@ -611,13 +678,21 @@ export class BasicRasterCanvas {
611
678
  if (!text) {
612
679
  return;
613
680
  }
614
- // Measure with the style the caller asked for, not with the default face.
681
+ // Recorded here, before either drawing path is chosen, so the finding does not
682
+ // depend on which one runs. Text drawn at zero alpha is therefore reported too:
683
+ // the statement is about whether the text *can* be laid out, which stays true when
684
+ // it happens to be invisible, and moving this past the paint check would mean
685
+ // calling it from both paths — one more place to forget.
686
+ this.textFeatures.note(text);
687
+ // Measure with the style the caller asked for. The width decides where centred and
688
+ // right-anchored text starts, so measuring bold text as regular shifted every such
689
+ // label left by the difference.
615
690
  //
616
- // The glyphs themselves come from one system font — selecting a family, a weight and
617
- // a slant means font discovery and matching, which this rasteriser does not do — but
618
- // the *width* has to follow the requested style regardless, because it decides where
619
- // centred and right-anchored text starts. Measuring bold text as regular shifted
620
- // every such label left by the difference.
691
+ // The glyphs are chosen for the same style — `fontChain` passes it to font
692
+ // discovery — but the *width* still comes from the static advance tables rather
693
+ // than the chosen face. That is deliberate: layout has to be identical on every
694
+ // machine, and it cannot be if it depends on which fonts are installed. The
695
+ // per-glyph advances are then normalised to this width.
621
696
  const measured = measureText(text, {
622
697
  size: fontSize,
623
698
  family: style.family ?? DEFAULT_TEXT_FAMILY,
@@ -626,30 +701,177 @@ export class BasicRasterCanvas {
626
701
  });
627
702
  const textWidth = measured > 0 ? measured : Math.max(1, fontSize * 0.5) * text.length;
628
703
  const startX = anchor === "middle" ? x - textWidth / 2 : anchor === "end" ? x - textWidth : x;
629
- // Try system font rasterization first (high quality filled glyphs)
630
- const font = loadSystemFont();
631
- if (font) {
632
- this.drawTextWithFont(font, startX, y, text, fontSize, textWidth, color, rotation);
704
+ // A chain, not a font: a face that covers `混合` may not cover `Mixed`, and the
705
+ // rasteriser has nothing to fall back to for a missing glyph except another
706
+ // face. The chain is ordered here; `drawTextWithFonts` picks per character.
707
+ // Complex scripts are shaped first: Arabic letters take their contextual forms and
708
+ // right-to-left runs are put in visual order, so the glyphs drawn below are the ones
709
+ // a reader expects rather than a row of isolated letters. `isSimpleText` gates it so
710
+ // that ordinary text — the overwhelming majority — takes exactly the path it did
711
+ // before, at exactly the same cost.
712
+ //
713
+ // `visualText` is what gets drawn; `text` is what was measured. Two independent
714
+ // decisions follow, and they are gated on different predicates because they answer
715
+ // different questions — conflating them broke a line in each direction.
716
+ //
717
+ // Shaping runs when the text might need it. Layout mode depends instead on whether
718
+ // the measurer's static tables describe the text: for a complex script they do not
719
+ // (Tamil measures at roughly half its real width), so normalising the glyph advances
720
+ // to that measurement squeezes the line into an overlapping mess, and the font's own
721
+ // advances have to be used. Latin carrying a stray bidi control is the converse case
722
+ // — it needs reordering but *is* measured correctly, and putting it on natural widths
723
+ // shifted every glyph by a rounding step, which is how inserting a zero-width control
724
+ // came to move visible text.
725
+ // The chain is resolved from the *original* text, before shaping. That order matters
726
+ // twice over. Font discovery should search for what the text actually is — nobody has a
727
+ // font installed "for U+FEE3" — and shaping needs the chain's answer, because a
728
+ // contextual form may only be used if something in the chain can draw it.
729
+ const fonts = this.fontChain(text, style);
730
+ const visualText = isSimpleText(text) || fonts.length === 0 ? text : shapeAgainstChain(text, fonts);
731
+ const useNaturalWidth = !isWellMeasuredText(text);
732
+ if (fonts.length > 0) {
733
+ // Text on natural widths is anchored and spaced by the font's own advances; the
734
+ // rest keeps the measured width, so centring stays identical to what it was.
735
+ const width = useNaturalWidth
736
+ ? this.naturalWidth(fonts, visualText, fontSize, new Map())
737
+ : textWidth;
738
+ const left = anchor === "middle" ? x - width / 2 : anchor === "end" ? x - width : x;
739
+ this.drawTextWithFonts(fonts, left, y, visualText, fontSize, useNaturalWidth ? undefined : textWidth, color, rotation);
633
740
  return;
634
741
  }
635
- // Fallback: stroke font
636
- this.drawTextStroke(startX, y, text, fontSize, textWidth, color, rotation);
742
+ // Fallback: stroke font. ASCII 32–126 only — everything else becomes `?`.
743
+ this.drawTextStroke(startX, y, visualText, fontSize, textWidth, color, rotation);
637
744
  }
638
- drawTextWithFont(font, startX, y, text, fontSize, textWidth, color, rotation) {
745
+ /** Warnings about text this canvas drew but could not lay out correctly. */
746
+ textWarnings() {
747
+ return this.textFeatures.warnings("This rasteriser", {
748
+ contextualForms: true,
749
+ visualOrder: true
750
+ });
751
+ }
752
+ /**
753
+ * Use these fonts for this canvas, instead of the process-wide registry.
754
+ *
755
+ * Scoped to the instance on purpose. This used to go through a module-level
756
+ * registry, which meant one render's fonts stayed in place for the next render that
757
+ * passed none — so two rasterisations in a process could not use different faces,
758
+ * and a caller who supplied a font once silently changed every later render.
759
+ *
760
+ * With `useSystemFonts: false` the chain is exactly `fonts`, which is what a
761
+ * reproducible render needs: font discovery cannot promise the same pixels on two
762
+ * machines.
763
+ */
764
+ setFonts(fonts, options = {}) {
765
+ this.ownFonts = parseInjectedFonts(fonts);
766
+ this.ownUseSystemFonts = options.useSystemFonts ?? true;
767
+ }
768
+ /** The ordered faces to try for `text`, in the style it asked for. */
769
+ fontChain(text, style) {
770
+ return resolveFontChain(text, this.ownFonts, this.ownUseSystemFonts, style);
771
+ }
772
+ /**
773
+ * Resolve one code point against the chain, returning the face that can draw it.
774
+ *
775
+ * `hasGlyph` is asked before `getOutline` because a `.notdef` gid must not count
776
+ * as coverage: it draws a box, so a chain has to keep looking rather than stop at
777
+ * the first face that technically answers.
778
+ */
779
+ resolveGlyph(fonts, codePoint, memo) {
780
+ // Both passes below — measuring the advances, then drawing — ask about the same
781
+ // characters, and a chain lookup is a walk over every face. Memoising within the
782
+ // call halves it, and repeated characters cost one lookup instead of one each.
783
+ if (memo.has(codePoint)) {
784
+ return memo.get(codePoint);
785
+ }
786
+ let found;
787
+ for (const font of fonts) {
788
+ if (!fontHasGlyph(font, codePoint)) {
789
+ continue;
790
+ }
791
+ const outline = font.getOutline(codePoint);
792
+ if (outline) {
793
+ found = { font, outline };
794
+ break;
795
+ }
796
+ }
797
+ memo.set(codePoint, found);
798
+ return found;
799
+ }
800
+ /**
801
+ * The advance to assume for a character no face can draw.
802
+ *
803
+ * It has to agree with what `measureText` assumed for the same character, or the
804
+ * `hScale` correction below redistributes the difference across the rest of the
805
+ * line. This was a flat `fontSize * 0.4` while `measureText` gives an ideograph a
806
+ * full em (`@utils/text-measure`'s `wideAdvance`), so a label mixing scripts had
807
+ * its *surviving* Latin letters stretched apart to fill the measured width — the
808
+ * missing CJK was invisible, but the visible text was visibly wrong too.
809
+ *
810
+ * A zero-width character takes nothing even when the face has no glyph for it: a
811
+ * combining acute the font cannot draw must not push the rest of the line along.
812
+ */
813
+ missingAdvance(codePoint, fontSize) {
814
+ if (isZeroWidthCodePoint(codePoint)) {
815
+ return 0;
816
+ }
817
+ return isFullWidthCodePoint(codePoint) ? fontSize : fontSize * 0.5;
818
+ }
819
+ /**
820
+ * Width of `text` as the chain's own advances give it, in user units.
821
+ *
822
+ * Used for shaped text, where the static advance tables cannot answer: they hold no
823
+ * entry for a presentation form, so `measureText` would report the width of the
824
+ * unshaped letters instead. Anchoring and normalisation then have to agree, and the
825
+ * only figure both can use is the one the faces actually being drawn provide.
826
+ */
827
+ naturalWidth(fonts, text, fontSize, memo) {
828
+ let total = 0;
829
+ for (const ch of text) {
830
+ const code = ch.codePointAt(0);
831
+ if (isNonPrintingControl(code)) {
832
+ continue;
833
+ }
834
+ const found = this.resolveGlyph(fonts, code, memo);
835
+ total += found
836
+ ? found.outline.advanceWidth * (fontSize / found.font.unitsPerEm)
837
+ : this.missingAdvance(code, fontSize);
838
+ }
839
+ return total;
840
+ }
841
+ drawTextWithFonts(fonts, startX, y, text, fontSize,
842
+ /**
843
+ * Width to stretch the glyph advances onto, or `undefined` to use the font's own.
844
+ *
845
+ * Normalising is right when the width came from the static advance tables for the
846
+ * *same* characters — it absorbs the difference between the table's estimate and the
847
+ * face actually used. It is wrong after shaping: the glyphs drawn are presentation
848
+ * forms whose advances are deliberately narrower than the isolated letters the table
849
+ * measured, so stretching them to the unshaped width pulled a joined Arabic word
850
+ * apart into evenly-spaced letters that merely *looked* connected.
851
+ */
852
+ normalizeTo, color, rotation) {
639
853
  const rgba = parseSvgColor(color);
640
854
  if (!rgba) {
641
855
  return;
642
856
  }
643
- const scale = fontSize / font.unitsPerEm;
644
857
  // Compute total advance from font metrics, then scale to match measured width.
645
858
  // Iterate by code point (not UTF-16 code unit) so surrogate pairs for
646
859
  // non-BMP characters resolve to a single glyph lookup.
860
+ const memo = new Map();
647
861
  let totalAdvance = 0;
648
862
  for (const ch of text) {
649
- const outline = font.getOutline(ch.codePointAt(0));
650
- totalAdvance += outline ? outline.advanceWidth * scale : fontSize * 0.4;
863
+ const code = ch.codePointAt(0);
864
+ // A formatting control is an instruction, not a character: it must not be
865
+ // looked up, charged an advance, or reported as undrawable.
866
+ if (isNonPrintingControl(code)) {
867
+ continue;
868
+ }
869
+ const found = this.resolveGlyph(fonts, code, memo);
870
+ totalAdvance += found
871
+ ? found.outline.advanceWidth * (fontSize / found.font.unitsPerEm)
872
+ : this.missingAdvance(code, fontSize);
651
873
  }
652
- const hScale = totalAdvance > 0 ? textWidth / totalAdvance : 1;
874
+ const hScale = normalizeTo !== undefined && totalAdvance > 0 ? normalizeTo / totalAdvance : 1;
653
875
  const theta = rotation && rotation.angle !== 0 ? (rotation.angle * Math.PI) / 180 : 0;
654
876
  const cos = Math.cos(theta);
655
877
  const sin = Math.sin(theta);
@@ -658,11 +880,23 @@ export class BasicRasterCanvas {
658
880
  let curX = startX;
659
881
  for (const ch of text) {
660
882
  const code = ch.codePointAt(0);
661
- const outline = font.getOutline(code);
662
- if (!outline) {
663
- curX += fontSize * 0.4 * hScale;
883
+ if (isNonPrintingControl(code)) {
664
884
  continue;
665
885
  }
886
+ const found = this.resolveGlyph(fonts, code, memo);
887
+ if (!found) {
888
+ // Nothing on this machine can draw it. Record it so the gap is reportable
889
+ // rather than a silent hole in the picture — but tab is not a gap: it is
890
+ // width without ink, and practically no font carries a glyph for it, so
891
+ // reporting it would tell the caller to install a font that cannot help.
892
+ if (code !== TAB) {
893
+ this.uncoveredCodePoints.add(code);
894
+ }
895
+ curX += this.missingAdvance(code, fontSize) * hScale;
896
+ continue;
897
+ }
898
+ const { font, outline } = found;
899
+ const scale = fontSize / font.unitsPerEm;
666
900
  const glyph = cachedRasterizeGlyph(outline, fontSize, font.unitsPerEm);
667
901
  if (glyph.pixels.length === 0) {
668
902
  curX += outline.advanceWidth * scale * hScale;
@@ -705,6 +939,22 @@ export class BasicRasterCanvas {
705
939
  let totalGlyphW = 0;
706
940
  for (const ch of text) {
707
941
  const code = ch.codePointAt(0);
942
+ // A formatting control has no glyph in any font, so substituting `?` for it
943
+ // would invent a character the text does not contain.
944
+ if (isNonPrintingControl(code)) {
945
+ continue;
946
+ }
947
+ // The stroke font covers ASCII 32-126 and nothing else, so anything outside
948
+ // it is drawn as `?`. That is a substitution, not a rendering: record it so a
949
+ // caller is told their CJK became punctuation rather than discovering it in
950
+ // the image. Tab is exempt for the same reason as above.
951
+ if (STROKE_FONT[code] === undefined && code !== TAB) {
952
+ this.uncoveredCodePoints.add(code);
953
+ }
954
+ if (code === TAB) {
955
+ totalGlyphW += TAB_EM_WIDTH; // width, no ink
956
+ continue;
957
+ }
708
958
  const glyph = STROKE_FONT[code] ?? STROKE_FONT[63];
709
959
  totalGlyphW += glyph ? glyph.w : 0.4;
710
960
  }
@@ -713,6 +963,13 @@ export class BasicRasterCanvas {
713
963
  let cx = startX;
714
964
  for (const ch of text) {
715
965
  const code = ch.codePointAt(0);
966
+ if (isNonPrintingControl(code)) {
967
+ continue;
968
+ }
969
+ if (code === TAB) {
970
+ cx += TAB_EM_WIDTH * fontSize * scale;
971
+ continue;
972
+ }
716
973
  const glyph = STROKE_FONT[code] ?? STROKE_FONT[63];
717
974
  if (glyph) {
718
975
  for (const stroke of glyph.d) {
@@ -742,6 +999,13 @@ export class BasicRasterCanvas {
742
999
  let cx = startX;
743
1000
  for (const ch of text) {
744
1001
  const code = ch.codePointAt(0);
1002
+ if (isNonPrintingControl(code)) {
1003
+ continue;
1004
+ }
1005
+ if (code === TAB) {
1006
+ cx += TAB_EM_WIDTH * fontSize * scale;
1007
+ continue;
1008
+ }
745
1009
  const glyph = STROKE_FONT[code] ?? STROKE_FONT[63];
746
1010
  if (glyph) {
747
1011
  for (const stroke of glyph.d) {