@stll/folio-core 0.15.13 → 0.17.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.
@@ -55,6 +55,15 @@ const TEXT_KEY = "#text";
55
55
  const ATTR_KEY = ":@";
56
56
  /** Character reference required to keep carriage returns through XML end-of-line normalization. */
57
57
  const XML_CARRIAGE_RETURN_REFERENCE = "
";
58
+ const EMPTY_NAMESPACE_SCOPE = { bindings: /* @__PURE__ */ new Map() };
59
+ const resolveNamespaceUri = (scope, prefix) => {
60
+ let current = scope;
61
+ while (current) {
62
+ const value = current.bindings.get(prefix);
63
+ if (value !== void 0) return value;
64
+ current = current.parent;
65
+ }
66
+ };
58
67
  /**
59
68
  * Convert a fast-xml-parser preserveOrder node into an XmlElement.
60
69
  *
@@ -62,7 +71,7 @@ const XML_CARRIAGE_RETURN_REFERENCE = "
";
62
71
  * (the tag name or `#text`) whose value is the children array, plus an
63
72
  * optional `:@` key holding the attributes object.
64
73
  */
65
- function fxpNodeToElement(node) {
74
+ function fxpNodeToElement(node, inheritedNamespaceScope = EMPTY_NAMESPACE_SCOPE) {
66
75
  if (TEXT_KEY in node) return {
67
76
  type: "text",
68
77
  text: node[TEXT_KEY]
@@ -76,8 +85,34 @@ function fxpNodeToElement(node) {
76
85
  name: key
77
86
  };
78
87
  if (attrs) element.attributes = attrs;
88
+ let localBindings = null;
89
+ if (attrs) for (const [attribute, value] of Object.entries(attrs)) {
90
+ if (attribute !== "xmlns" && !attribute.startsWith("xmlns:")) continue;
91
+ localBindings ??= /* @__PURE__ */ new Map();
92
+ const prefix = attribute === "xmlns" ? "" : attribute.slice(6);
93
+ localBindings.set(prefix, value);
94
+ }
95
+ const namespaceScope = localBindings === null ? inheritedNamespaceScope : {
96
+ bindings: localBindings,
97
+ parent: inheritedNamespaceScope
98
+ };
99
+ Object.defineProperty(element, "namespaceScope", {
100
+ configurable: false,
101
+ enumerable: false,
102
+ value: namespaceScope,
103
+ writable: false
104
+ });
105
+ const colonIndex = key.indexOf(":");
106
+ const prefix = colonIndex === -1 ? "" : key.slice(0, colonIndex);
107
+ const namespaceUri = resolveNamespaceUri(namespaceScope, prefix);
108
+ if (namespaceUri !== void 0) Object.defineProperty(element, "namespaceUri", {
109
+ configurable: false,
110
+ enumerable: false,
111
+ value: namespaceUri,
112
+ writable: false
113
+ });
79
114
  if (children.length > 0) {
80
- for (let index = 0; index < children.length; index += 1) children[index] = fxpNodeToElement(children[index]);
115
+ for (let index = 0; index < children.length; index += 1) children[index] = fxpNodeToElement(children[index], namespaceScope);
81
116
  element.elements = children;
82
117
  }
83
118
  return element;
@@ -159,6 +194,18 @@ function getNamespacePrefix(name) {
159
194
  const colonIndex = name.indexOf(":");
160
195
  return colonIndex !== -1 ? name.slice(0, colonIndex) : null;
161
196
  }
197
+ /** Namespace URI resolved from the element's in-scope XML declarations. */
198
+ const getNamespaceUri = (element) => element.namespaceUri;
199
+ /** Get an attribute whose prefix resolves to one of the accepted namespace URIs. */
200
+ function getAttributeByNamespaceUri(element, namespaceUris, localName) {
201
+ if (!element?.attributes) return null;
202
+ for (const [name, value] of Object.entries(element.attributes)) {
203
+ if (value === void 0 || getLocalName(name) !== localName) continue;
204
+ const prefix = getNamespacePrefix(name);
205
+ if (prefix !== null && namespaceUris.has(resolveNamespaceUri(element.namespaceScope, prefix) ?? "")) return String(value);
206
+ }
207
+ return null;
208
+ }
162
209
  function hasLocalName(name, localName) {
163
210
  if (!name) return false;
164
211
  if (name === localName) return true;
@@ -640,4 +687,4 @@ function cloneWithXmlnsDeclarations(element, xmlnsDecls) {
640
687
  return element;
641
688
  }
642
689
  //#endregion
643
- export { NAMESPACES, cloneWithXmlnsDeclarations, collectXmlnsDeclarations, elementToXml, findAllDeep, findByFullName, findChild, findChildByLocalName, findChildren, findChildrenByLocalName, findDeep, getAttribute, getAttributeAny, getAttributeAnyPrefix, getAttributes, getChildElements, getLocalName, getNamespacePrefix, getTextContent, hasChild, hasFlag, matchesName, mergeXmlnsDeclarations, parseBooleanElement, parseColorElement, parseNumberingLevelAttribute, parseNumericAttribute, parseTableMeasurementValue, parseXml, parseXmlDocument };
690
+ export { NAMESPACES, cloneWithXmlnsDeclarations, collectXmlnsDeclarations, elementToXml, findAllDeep, findByFullName, findChild, findChildByLocalName, findChildren, findChildrenByLocalName, findDeep, getAttribute, getAttributeAny, getAttributeAnyPrefix, getAttributeByNamespaceUri, getAttributes, getChildElements, getLocalName, getNamespacePrefix, getNamespaceUri, getTextContent, hasChild, hasFlag, matchesName, mergeXmlnsDeclarations, parseBooleanElement, parseColorElement, parseNumberingLevelAttribute, parseNumericAttribute, parseTableMeasurementValue, parseXml, parseXmlDocument };
@@ -246,6 +246,8 @@ function extractRunFormatting(marks, theme) {
246
246
  if (font) formatting.fontFamily = font;
247
247
  const eastAsiaFont = resolveEastAsiaThemeFont(attrs, theme);
248
248
  if (eastAsiaFont) formatting.eastAsiaFontFamily = eastAsiaFont;
249
+ const complexScriptFont = resolveComplexScriptThemeFont(attrs, theme);
250
+ if (complexScriptFont) formatting.complexScriptFontFamily = complexScriptFont;
249
251
  break;
250
252
  }
251
253
  case "language": {
@@ -361,6 +363,9 @@ const resolveWesternThemeFont = (fontFamily, theme) => {
361
363
  const themeRef = fontFamily.asciiTheme ?? fontFamily.hAnsiTheme;
362
364
  return (themeRef ? resolveThemeFont(themeRef, theme?.fontScheme) : null) ?? fontFamily.ascii ?? fontFamily.hAnsi ?? void 0;
363
365
  };
366
+ const resolveComplexScriptThemeFont = (fontFamily, theme) => {
367
+ return (fontFamily.cstheme ? resolveThemeFont(fontFamily.cstheme, theme?.fontScheme) : null) ?? fontFamily.cs ?? void 0;
368
+ };
364
369
  const resolveEastAsiaThemeFont = (fontFamily, theme) => {
365
370
  return (fontFamily.eastAsiaTheme ? resolveThemeFont(fontFamily.eastAsiaTheme, theme?.fontScheme) : null) ?? fontFamily.eastAsia ?? void 0;
366
371
  };
@@ -404,6 +409,8 @@ function paragraphRunDefaults(pmAttrs, theme) {
404
409
  if (fontFamily) result.fontFamily = fontFamily;
405
410
  const eastAsiaFontFamily = defaultTextFormatting.fontFamily ? resolveEastAsiaThemeFont(defaultTextFormatting.fontFamily, theme) : void 0;
406
411
  if (eastAsiaFontFamily) result.eastAsiaFontFamily = eastAsiaFontFamily;
412
+ const complexScriptFontFamily = defaultTextFormatting.fontFamily ? resolveComplexScriptThemeFont(defaultTextFormatting.fontFamily, theme) : void 0;
413
+ if (complexScriptFontFamily) result.complexScriptFontFamily = complexScriptFontFamily;
407
414
  if (defaultTextFormatting.language) result.language = { ...defaultTextFormatting.language };
408
415
  if (defaultTextFormatting.fontSize !== void 0) result.fontSize = defaultTextFormatting.fontSize / 2;
409
416
  if (defaultTextFormatting.bold !== void 0) result.bold = defaultTextFormatting.bold;
@@ -170,7 +170,7 @@ const paragraphMeasureCache = /* @__PURE__ */ new Map();
170
170
  */
171
171
  function hashParagraphBlock(block) {
172
172
  const parts = [`lbp:${getLineBreakProviderGeneration()}`];
173
- for (const run of block.runs) if (run.kind === "text") parts.push(`t:${run.text}|${run.fontFamily}|${run.eastAsiaFontFamily}|${run.fontSize}|${run.bold}|${run.italic}|${run.allCaps}|${run.smallCaps}|${run.horizontalScale}|${run.letterSpacing}|${run.language?.val}|${run.language?.eastAsia}|${run.language?.bidi}`);
173
+ for (const run of block.runs) if (run.kind === "text") parts.push(`t:${run.text}|${run.fontFamily}|${run.eastAsiaFontFamily}|${run.complexScriptFontFamily}|${run.fontSize}|${run.bold}|${run.italic}|${run.allCaps}|${run.smallCaps}|${run.horizontalScale}|${run.letterSpacing}|${run.language?.val}|${run.language?.eastAsia}|${run.language?.bidi}`);
174
174
  else if (run.kind === "tab") parts.push(`tab:${run.width}`);
175
175
  else if (run.kind === "image") parts.push(`img:${run.width}x${run.height}:${run.exactLineHeight === true ? "exact" : "text"}`);
176
176
  else if (run.kind === "lineBreak") parts.push("br");
@@ -1,4 +1,4 @@
1
- import { hasCjk, isCjkCodePoint, segmentByScript } from "../../utils/scriptSegments.js";
1
+ import { SCRIPT_CLASS, hasCjk, hasComplexScript, scriptClassOf, segmentByScript } from "../../utils/scriptSegments.js";
2
2
  import { getCachedFontMetrics, getCachedTextWidth, getTextWidthCacheGeneration, setCachedFontMetrics, setCachedTextWidth } from "./cache.js";
3
3
  import { buildFontString, getResolvedData, ptToPx } from "./measureHelpers.js";
4
4
  import { setMeasureProvider } from "./measureProvider.js";
@@ -118,7 +118,7 @@ function canvasGetFontMetrics(style) {
118
118
  function canvasMeasureTextWidth(text, style) {
119
119
  if (!text) return 0;
120
120
  const measuredText = applyTextTransform(text, style);
121
- if (style.eastAsiaFontFamily && !style.letterSpacing && hasCjk(measuredText)) return measureMixedScriptWidth(measuredText, style);
121
+ if (!style.letterSpacing && needsPerScriptFonts(style, measuredText)) return measureMixedScriptWidth(measuredText, style);
122
122
  const ctx = getCanvasContext();
123
123
  const font = buildFontString(style);
124
124
  const letterSpacing = style.letterSpacing ?? 0;
@@ -173,14 +173,25 @@ function glyphAdvanceStyle(style, fontFamily) {
173
173
  * horizontal scale are applied once over the whole string so the total matches
174
174
  * the painter, which renders the same segments as sibling spans.
175
175
  */
176
+ /**
177
+ * Whether this run needs more than one font, i.e. it carries a per-script slot
178
+ * AND text that would select it. Keeps the all-Latin path on a single font.
179
+ */
180
+ function needsPerScriptFonts(style, measuredText) {
181
+ if (style.eastAsiaFontFamily && hasCjk(measuredText)) return true;
182
+ return Boolean(style.complexScriptFontFamily) && hasComplexScript(measuredText);
183
+ }
184
+ /** The font slot a script class selects, falling back to the western one. */
185
+ function scriptFontFamily(style, script) {
186
+ if (script === SCRIPT_CLASS.eastAsia) return style.eastAsiaFontFamily ?? style.fontFamily;
187
+ if (script === SCRIPT_CLASS.complex) return style.complexScriptFontFamily ?? style.fontFamily;
188
+ return style.fontFamily;
189
+ }
176
190
  function measureMixedScriptWidth(measuredText, style) {
177
191
  const letterSpacing = style.letterSpacing ?? 0;
178
192
  const horizontalScale = getHorizontalScaleFactor(style);
179
193
  let glyphWidth = 0;
180
- for (const segment of segmentByScript(measuredText)) {
181
- const fontFamily = segment.isCjk ? style.eastAsiaFontFamily : style.fontFamily;
182
- glyphWidth += canvasMeasureTextWidth(segment.text, glyphAdvanceStyle(style, fontFamily));
183
- }
194
+ for (const segment of segmentByScript(measuredText)) glyphWidth += canvasMeasureTextWidth(segment.text, glyphAdvanceStyle(style, scriptFontFamily(style, segment.script)));
184
195
  let width = glyphWidth;
185
196
  if (letterSpacing) {
186
197
  const codePoints = countCodePoints(measuredText);
@@ -251,6 +262,10 @@ function canvasMeasureRun(text, style) {
251
262
  ...style,
252
263
  fontFamily: style.eastAsiaFontFamily
253
264
  }) : void 0;
265
+ const complexScriptFont = style.complexScriptFontFamily !== void 0 && !style.letterSpacing ? buildFontString({
266
+ ...style,
267
+ fontFamily: style.complexScriptFontFamily
268
+ }) : void 0;
254
269
  const letterSpacing = style.letterSpacing ?? 0;
255
270
  const scale = getHorizontalScaleFactor(style);
256
271
  const charWidths = [];
@@ -259,7 +274,10 @@ function canvasMeasureRun(text, style) {
259
274
  for (const char of text) {
260
275
  const cp = char.codePointAt(0);
261
276
  const measured = applyTextTransform(char, style);
262
- if (eastAsiaFont !== void 0) ctx.font = isCjkCodePoint(cp) ? eastAsiaFont : baseFont;
277
+ if (eastAsiaFont !== void 0 || complexScriptFont !== void 0) {
278
+ const script = scriptClassOf(cp);
279
+ ctx.font = (script === SCRIPT_CLASS.eastAsia ? eastAsiaFont : void 0) ?? (script === SCRIPT_CLASS.complex ? complexScriptFont : void 0) ?? baseFont;
280
+ }
263
281
  let charWidth = ctx.measureText(measured).width;
264
282
  if (letterSpacing && offset + char.length < text.length) charWidth += letterSpacing;
265
283
  charWidth *= scale;
@@ -28,6 +28,7 @@ function buildRunFontStyle(run, fallbackFontFamily, fallbackFontSize) {
28
28
  return {
29
29
  fontFamily: run.fontFamily ?? fallbackFontFamily,
30
30
  ...run.eastAsiaFontFamily !== void 0 ? { eastAsiaFontFamily: run.eastAsiaFontFamily } : {},
31
+ ...run.complexScriptFontFamily !== void 0 ? { complexScriptFontFamily: run.complexScriptFontFamily } : {},
31
32
  fontSize,
32
33
  ...run.bold !== void 0 ? { bold: run.bold } : {},
33
34
  ...run.italic !== void 0 ? { italic: run.italic } : {},
@@ -21,6 +21,11 @@ type FontStyle = {
21
21
  * and click positioning stay in sync.
22
22
  */
23
23
  eastAsiaFontFamily?: string;
24
+ /**
25
+ * Complex-script font for Arabic, Hebrew, Indic and South-East Asian code
26
+ * points. Same contract as `eastAsiaFontFamily`, over a different slot.
27
+ */
28
+ complexScriptFontFamily?: string;
24
29
  fontSize?: number;
25
30
  bold?: boolean;
26
31
  italic?: boolean;
@@ -37,6 +37,15 @@ type RunFormatting = {
37
37
  * to `fontFamily`.
38
38
  */
39
39
  eastAsiaFontFamily?: string;
40
+ /**
41
+ * Resolved complex-script font (`w:cs` / `cstheme`). Arabic, Hebrew, Indic
42
+ * and South-East Asian code points in this run measure and paint with this
43
+ * font; the rest keeps `fontFamily` (ascii/hAnsi). Mirrors
44
+ * `eastAsiaFontFamily` exactly, including the shared segmentation. Absent
45
+ * means complex-script text falls back to `fontFamily`, which is what Word
46
+ * does NOT do and is why this slot has to reach layout at all.
47
+ */
48
+ complexScriptFontFamily?: string;
40
49
  /** Run language metadata resolved from `w:lang`. */
41
50
  language?: {
42
51
  val?: string;
@@ -0,0 +1,80 @@
1
+ //#region src/layout-painter/cursiveJoiners.d.ts
2
+ /**
3
+ * Repair cursive letter connections that a face change severed.
4
+ *
5
+ * Browsers shape across an inline box boundary only while no shaping-relevant
6
+ * property changes. Colour and underline are safe, so tracked-change and
7
+ * comment marks already join. A face change is not: bold, italic, a different
8
+ * size or a different family selects another font, shaping stops there, and a
9
+ * cursive word split mid-word by such a run falls back to isolated forms and
10
+ * visibly comes apart. Word joins straight through the same boundary.
11
+ *
12
+ * The repair is a zero-width joiner on each side of the boundary, carried in its
13
+ * OWN span rather than appended to the run's text. That distinction is
14
+ * load-bearing: `data-pm-start`/`data-pm-end` spans map DOM text offsets back to
15
+ * ProseMirror positions for hit-testing, so growing a run's text node by a
16
+ * character would desync every offset after it. A joiner span carries no pm
17
+ * attributes, so the offset contract is untouched.
18
+ *
19
+ * Whether two runs share a face is decided by running the painter's own
20
+ * `applyRunStyles` over a probe element for each side and diffing the result,
21
+ * rather than by re-deriving the face from run fields. There is no second copy
22
+ * of the font decisions to drift from the first, and a property this module has
23
+ * never heard of counts as a face change: over-inserting a joiner is invisible,
24
+ * missing one is not.
25
+ *
26
+ * KNOWN RESIDUAL — the measurer does not see this repair. Canvas `measureText`
27
+ * ignores a joiner at a string edge (measuring the two halves with and without
28
+ * one returns bit-identical widths), while DOM layout applies the joined forms.
29
+ * So a repaired word paints at a different width than the measurer reserved, by
30
+ * roughly the difference between isolated and medial advances.
31
+ *
32
+ * Which DIRECTION depends on the font: measured against two Arabic fallback
33
+ * faces the divergence was -2.7px on one and +2.5px on the other. So it is a
34
+ * bounded disagreement that can push a line either way, NOT a safe
35
+ * over-reservation, and a line can therefore come out marginally too long. It
36
+ * arises only on words that contain a face change, and
37
+ * `tests/visual/measure-parity.spec.ts` holds it to a budget. Closing it needs widths from a real shaper
38
+ * rather than canvas; until then the trade is a correct rendering against a
39
+ * slightly wrong measurement, which beats a measurement that exactly matches a
40
+ * visibly broken rendering.
41
+ */
42
+ /** Marks a span as joiner furniture: no text of its own, no pm positions. */
43
+ declare const JOINER_DATASET_KEY = "docxJoiner";
44
+ /** `applyRunStyles`, injected so this module does not import the painter back. */
45
+ type ApplyRunStyles<TRun> = (element: HTMLElement, run: TRun) => void;
46
+ /** Which side(s) of a run need a joiner span. */
47
+ type JoinerSides = {
48
+ leading: boolean;
49
+ trailing: boolean;
50
+ };
51
+ type PlanOptions<TRun> = {
52
+ /** Runs in paint order, already sliced for the line and script-split. */
53
+ runs: readonly TRun[];
54
+ /** Text of a run, or undefined for a run that paints no text (image, break). */
55
+ textOf: (run: TRun) => string | undefined;
56
+ applyRunStyles: ApplyRunStyles<TRun>;
57
+ doc: Document;
58
+ };
59
+ /**
60
+ * Decide, for each run, whether it needs a joiner on either side.
61
+ *
62
+ * Only adjacent text-bearing runs are considered: an image or a tab between two
63
+ * words means they were never connected, so no joiner belongs there.
64
+ */
65
+ declare function planCursiveJoiners<TRun>({ runs, textOf, applyRunStyles, doc }: PlanOptions<TRun>): Map<TRun, JoinerSides>;
66
+ /**
67
+ * Build a joiner span wearing `run`'s own painted styles, so shaping crosses
68
+ * from that run into the joiner and takes the joined form.
69
+ *
70
+ * Without the matching styles the joiner would sit in a different face and the
71
+ * repair would be a no-op that still looked applied.
72
+ */
73
+ declare function createJoinerSpan<TRun>(run: TRun, applyRunStyles: ApplyRunStyles<TRun>, doc: Document): HTMLElement;
74
+ /**
75
+ * A run element wrapped with whatever joiners its boundaries need, ready to
76
+ * spread into `append`. Returns the element alone when no repair applies.
77
+ */
78
+ declare function withCursiveJoiners<TRun>(runEl: HTMLElement, run: TRun, plan: Map<TRun, JoinerSides>, applyRunStyles: ApplyRunStyles<TRun>, doc: Document): HTMLElement[];
79
+ //#endregion
80
+ export { ApplyRunStyles, JOINER_DATASET_KEY, JoinerSides, createJoinerSpan, planCursiveJoiners, withCursiveJoiners };
@@ -0,0 +1,199 @@
1
+ import { hasCursiveLetter, joinsAcrossBoundary } from "../utils/cursiveJoining.js";
2
+ //#region src/layout-painter/cursiveJoiners.ts
3
+ /**
4
+ * Repair cursive letter connections that a face change severed.
5
+ *
6
+ * Browsers shape across an inline box boundary only while no shaping-relevant
7
+ * property changes. Colour and underline are safe, so tracked-change and
8
+ * comment marks already join. A face change is not: bold, italic, a different
9
+ * size or a different family selects another font, shaping stops there, and a
10
+ * cursive word split mid-word by such a run falls back to isolated forms and
11
+ * visibly comes apart. Word joins straight through the same boundary.
12
+ *
13
+ * The repair is a zero-width joiner on each side of the boundary, carried in its
14
+ * OWN span rather than appended to the run's text. That distinction is
15
+ * load-bearing: `data-pm-start`/`data-pm-end` spans map DOM text offsets back to
16
+ * ProseMirror positions for hit-testing, so growing a run's text node by a
17
+ * character would desync every offset after it. A joiner span carries no pm
18
+ * attributes, so the offset contract is untouched.
19
+ *
20
+ * Whether two runs share a face is decided by running the painter's own
21
+ * `applyRunStyles` over a probe element for each side and diffing the result,
22
+ * rather than by re-deriving the face from run fields. There is no second copy
23
+ * of the font decisions to drift from the first, and a property this module has
24
+ * never heard of counts as a face change: over-inserting a joiner is invisible,
25
+ * missing one is not.
26
+ *
27
+ * KNOWN RESIDUAL — the measurer does not see this repair. Canvas `measureText`
28
+ * ignores a joiner at a string edge (measuring the two halves with and without
29
+ * one returns bit-identical widths), while DOM layout applies the joined forms.
30
+ * So a repaired word paints at a different width than the measurer reserved, by
31
+ * roughly the difference between isolated and medial advances.
32
+ *
33
+ * Which DIRECTION depends on the font: measured against two Arabic fallback
34
+ * faces the divergence was -2.7px on one and +2.5px on the other. So it is a
35
+ * bounded disagreement that can push a line either way, NOT a safe
36
+ * over-reservation, and a line can therefore come out marginally too long. It
37
+ * arises only on words that contain a face change, and
38
+ * `tests/visual/measure-parity.spec.ts` holds it to a budget. Closing it needs widths from a real shaper
39
+ * rather than canvas; until then the trade is a correct rendering against a
40
+ * slightly wrong measurement, which beats a measurement that exactly matches a
41
+ * visibly broken rendering.
42
+ */
43
+ /** U+200D ZERO WIDTH JOINER — Joining_Type C, so it joins on both sides. */
44
+ const ZERO_WIDTH_JOINER = "‍";
45
+ /** Marks a span as joiner furniture: no text of its own, no pm positions. */
46
+ const JOINER_DATASET_KEY = "docxJoiner";
47
+ /**
48
+ * Inline properties that `applyRunStyles` may set which provably cannot change
49
+ * how text shapes, and so must NOT be read as a face change.
50
+ *
51
+ * A deny-list rather than an allow-list on purpose: an unlisted new property is
52
+ * treated as face-changing, which inserts a harmless extra joiner instead of
53
+ * silently stopping the repair. Fails toward the visible-correct rendering.
54
+ */
55
+ const SHAPING_NEUTRAL_STYLE_PROPERTIES = /* @__PURE__ */ new Set([
56
+ "color",
57
+ "--doc-run-color",
58
+ "backgroundColor",
59
+ "background-color",
60
+ "textDecoration",
61
+ "text-decoration",
62
+ "textDecorationColor",
63
+ "text-decoration-color",
64
+ "textDecorationStyle",
65
+ "text-decoration-style",
66
+ "textUnderlineOffset",
67
+ "text-underline-offset",
68
+ "verticalAlign",
69
+ "vertical-align",
70
+ "opacity",
71
+ "borderBottom",
72
+ "border-bottom",
73
+ "outline"
74
+ ]);
75
+ /**
76
+ * The style declaration a run paints with, as a plain comparable record.
77
+ *
78
+ * Reading the keys the painter actually assigned (rather than enumerating a
79
+ * CSSStyleDeclaration) keeps this working against both the real DOM and the
80
+ * minimal fake the painter tests use.
81
+ */
82
+ function paintedStyle(run, applyRunStyles, doc) {
83
+ const probe = doc.createElement("span");
84
+ applyRunStyles(probe, run);
85
+ const style = {};
86
+ for (const [property, value] of styleEntries(probe.style)) {
87
+ if (value === "") continue;
88
+ if (SHAPING_NEUTRAL_STYLE_PROPERTIES.has(property)) continue;
89
+ style[property] = value;
90
+ }
91
+ return style;
92
+ }
93
+ /**
94
+ * Enumerate the properties a declaration actually has set.
95
+ *
96
+ * A real `CSSStyleDeclaration` is not a plain object: `Object.entries` on one
97
+ * yields its INDEXED entries, so `["0", "font-family"]` rather than
98
+ * `["fontFamily", "Arial"]`, which compares the list of property names instead
99
+ * of their values. The painter's test fake IS a plain object. Both shapes have
100
+ * to work, and the difference between them is invisible in unit tests, so it is
101
+ * handled here explicitly rather than left to whichever one a caller happens to
102
+ * pass. Real declarations report kebab-case names, including custom properties.
103
+ */
104
+ function styleEntries(style) {
105
+ const declaration = style;
106
+ if (typeof declaration.length === "number" && typeof declaration.item === "function" && typeof declaration.getPropertyValue === "function") {
107
+ const entries = [];
108
+ for (let index = 0; index < declaration.length; index++) {
109
+ const property = declaration.item(index);
110
+ entries.push([property, declaration.getPropertyValue(property)]);
111
+ }
112
+ return entries;
113
+ }
114
+ return Object.entries(style).filter((entry) => typeof entry[1] === "string");
115
+ }
116
+ const sameFace = (a, b) => {
117
+ const keys = /* @__PURE__ */ new Set([...Object.keys(a), ...Object.keys(b)]);
118
+ for (const key of keys) if (a[key] !== b[key]) return false;
119
+ return true;
120
+ };
121
+ /**
122
+ * Decide, for each run, whether it needs a joiner on either side.
123
+ *
124
+ * Only adjacent text-bearing runs are considered: an image or a tab between two
125
+ * words means they were never connected, so no joiner belongs there.
126
+ */
127
+ function planCursiveJoiners({ runs, textOf, applyRunStyles, doc }) {
128
+ const plan = /* @__PURE__ */ new Map();
129
+ if (runs.length < 2) return plan;
130
+ let lineHasCursive = false;
131
+ for (const run of runs) {
132
+ const text = textOf(run);
133
+ if (text !== void 0 && hasCursiveLetter(text)) {
134
+ lineHasCursive = true;
135
+ break;
136
+ }
137
+ }
138
+ if (!lineHasCursive) return plan;
139
+ const styleCache = /* @__PURE__ */ new Map();
140
+ const styleOf = (run) => {
141
+ const cached = styleCache.get(run);
142
+ if (cached) return cached;
143
+ const computed = paintedStyle(run, applyRunStyles, doc);
144
+ styleCache.set(run, computed);
145
+ return computed;
146
+ };
147
+ const mark = (run, side) => {
148
+ const existing = plan.get(run);
149
+ if (existing) {
150
+ existing[side] = true;
151
+ return;
152
+ }
153
+ plan.set(run, {
154
+ leading: side === "leading",
155
+ trailing: side === "trailing"
156
+ });
157
+ };
158
+ for (let i = 0; i < runs.length - 1; i++) {
159
+ const before = runs[i];
160
+ const after = runs[i + 1];
161
+ const beforeText = textOf(before);
162
+ const afterText = textOf(after);
163
+ if (beforeText === void 0 || afterText === void 0) continue;
164
+ if (!joinsAcrossBoundary(beforeText, afterText)) continue;
165
+ if (sameFace(styleOf(before), styleOf(after))) continue;
166
+ mark(before, "trailing");
167
+ mark(after, "leading");
168
+ }
169
+ return plan;
170
+ }
171
+ /**
172
+ * Build a joiner span wearing `run`'s own painted styles, so shaping crosses
173
+ * from that run into the joiner and takes the joined form.
174
+ *
175
+ * Without the matching styles the joiner would sit in a different face and the
176
+ * repair would be a no-op that still looked applied.
177
+ */
178
+ function createJoinerSpan(run, applyRunStyles, doc) {
179
+ const span = doc.createElement("span");
180
+ applyRunStyles(span, run);
181
+ span.dataset[JOINER_DATASET_KEY] = "true";
182
+ span.textContent = ZERO_WIDTH_JOINER;
183
+ return span;
184
+ }
185
+ /**
186
+ * A run element wrapped with whatever joiners its boundaries need, ready to
187
+ * spread into `append`. Returns the element alone when no repair applies.
188
+ */
189
+ function withCursiveJoiners(runEl, run, plan, applyRunStyles, doc) {
190
+ const sides = plan.get(run);
191
+ if (!sides) return [runEl];
192
+ const elements = [];
193
+ if (sides.leading) elements.push(createJoinerSpan(run, applyRunStyles, doc));
194
+ elements.push(runEl);
195
+ if (sides.trailing) elements.push(createJoinerSpan(run, applyRunStyles, doc));
196
+ return elements;
197
+ }
198
+ //#endregion
199
+ export { JOINER_DATASET_KEY, createJoinerSpan, planCursiveJoiners, withCursiveJoiners };
@@ -1323,6 +1323,7 @@ function runContentKey(run) {
1323
1323
  if (run.color) parts.push(`c:${run.color}`);
1324
1324
  if (run.highlight) parts.push(`hi:${run.highlight}`);
1325
1325
  if (run.fontFamily) parts.push(`ff:${run.fontFamily}`);
1326
+ if (run.complexScriptFontFamily) parts.push(`cs:${run.complexScriptFontFamily}`);
1326
1327
  if (run.eastAsiaFontFamily) parts.push(`ea:${run.eastAsiaFontFamily}`);
1327
1328
  if (run.fontSize !== void 0) parts.push(`fs:${run.fontSize}`);
1328
1329
  if (run.letterSpacing !== void 0) parts.push(`ls:${run.letterSpacing}`);
@@ -12,8 +12,9 @@ import { resolveFontFamily } from "../utils/fontResolver.js";
12
12
  import "../utils/fontWeights.js";
13
13
  import { inlineImageBoundingBox, parseRotationDegrees, rotatedBoundingBox } from "../utils/rotationBoundingBox.js";
14
14
  import { applySanitizedImageSrc } from "../utils/sanitizeImageSrc.js";
15
- import { hasCjk, segmentByScript } from "../utils/scriptSegments.js";
15
+ import { SCRIPT_CLASS, hasCjk, hasComplexScript, segmentByScript } from "../utils/scriptSegments.js";
16
16
  import { borderStrokeToCss, resolveParagraphBorderHorizontalOutsets } from "./borderStroke.js";
17
+ import { planCursiveJoiners, withCursiveJoiners } from "./cursiveJoiners.js";
17
18
  import { getAutomaticTextColorForBackground } from "./documentColors.js";
18
19
  import { applyImageBorder, applyImageVisualAttrs, hasImageCrop, hasImageVisualAttrs, wrapImageWithCrop } from "./renderImage.js";
19
20
  import { resolveImageLineAlign } from "./renderUtils.js";
@@ -591,7 +592,10 @@ function renderFieldRun(run, doc, context) {
591
592
  kind: "text",
592
593
  text
593
594
  };
594
- if (resolvedRun.eastAsiaFontFamily !== void 0 && !resolvedRun.letterSpacing && hasCjk(text)) {
595
+ if (needsPerScriptSpans({
596
+ ...resolvedRun,
597
+ text
598
+ })) {
595
599
  const wrapper = doc.createElement("span");
596
600
  applyPmPositions(wrapper, resolvedRun.pmStart, resolvedRun.pmEnd);
597
601
  if (resolvedRun.horizontalScale && resolvedRun.horizontalScale !== 100) {
@@ -604,7 +608,7 @@ function renderFieldRun(run, doc, context) {
604
608
  const segmentRun = {
605
609
  ...segmentBase,
606
610
  text: segment.text,
607
- ...segment.isCjk ? { fontFamily: resolvedRun.eastAsiaFontFamily } : {}
611
+ ...scriptFontOverride(resolvedRun, segment.script)
608
612
  };
609
613
  wrapper.append(renderTextRun(segmentRun, doc));
610
614
  }
@@ -719,6 +723,26 @@ function sliceRunsForLine(block, line) {
719
723
  return result;
720
724
  }
721
725
  /**
726
+ * The font a script segment paints with, mirroring the measurer's
727
+ * `scriptFontFamily`. Returning undefined leaves the run's own `fontFamily`.
728
+ */
729
+ const scriptFontOverride = (run, script) => {
730
+ if (script === SCRIPT_CLASS.eastAsia && run.eastAsiaFontFamily !== void 0) return { fontFamily: run.eastAsiaFontFamily };
731
+ if (script === SCRIPT_CLASS.complex && run.complexScriptFontFamily !== void 0) return { fontFamily: run.complexScriptFontFamily };
732
+ return {};
733
+ };
734
+ /**
735
+ * Whether a run needs per-script sibling spans: it carries a script-specific
736
+ * font slot AND text that selects it. A letter-spaced run is excluded because
737
+ * CSS letter-spacing does not bridge sibling spans, and the measurer skips the
738
+ * same case so the widths still agree.
739
+ */
740
+ const needsPerScriptSpans = (run) => {
741
+ if (run.letterSpacing) return false;
742
+ if (run.eastAsiaFontFamily !== void 0 && hasCjk(run.text)) return true;
743
+ return run.complexScriptFontFamily !== void 0 && hasComplexScript(run.text);
744
+ };
745
+ /**
722
746
  * Split each text run carrying an East-Asian font into per-script sub-runs, so
723
747
  * CJK code points render with `eastAsiaFontFamily` and the rest with
724
748
  * `fontFamily`. Each sub-run gets a contiguous, exact `pmStart`/`pmEnd` (the
@@ -730,7 +754,7 @@ function sliceRunsForLine(block, line) {
730
754
  function splitTextRunsByEastAsia(runs) {
731
755
  const result = [];
732
756
  for (const run of runs) {
733
- if (!isTextRun(run) || run.eastAsiaFontFamily === void 0 || run.letterSpacing || !hasCjk(run.text)) {
757
+ if (!isTextRun(run) || !needsPerScriptSpans(run)) {
734
758
  result.push(run);
735
759
  continue;
736
760
  }
@@ -739,7 +763,7 @@ function splitTextRunsByEastAsia(runs) {
739
763
  result.push({
740
764
  ...run,
741
765
  text: segment.text,
742
- ...segment.isCjk ? { fontFamily: run.eastAsiaFontFamily } : {},
766
+ ...scriptFontOverride(run, segment.script),
743
767
  ...run.pmStart !== void 0 ? {
744
768
  pmStart: run.pmStart + offset,
745
769
  pmEnd: run.pmStart + offset + segment.text.length
@@ -842,6 +866,7 @@ function runMeasureStyle(run) {
842
866
  ...run.letterSpacing !== void 0 ? { letterSpacing: run.letterSpacing } : {},
843
867
  ...run.smallCaps !== void 0 ? { smallCaps: run.smallCaps } : {},
844
868
  ...run.eastAsiaFontFamily !== void 0 ? { eastAsiaFontFamily: run.eastAsiaFontFamily } : {},
869
+ ...run.complexScriptFontFamily !== void 0 ? { complexScriptFontFamily: run.complexScriptFontFamily } : {},
845
870
  kerning: getRunFontKerningMode(run, 11) === FONT_KERNING_MODE.enabled
846
871
  };
847
872
  }
@@ -956,11 +981,14 @@ function createTextMeasurer(doc) {
956
981
  if (style.bold) fontPrefixParts.push("700");
957
982
  fontPrefixParts.push(`${fontSizePx}px`);
958
983
  const fontPrefix = fontPrefixParts.join(" ");
959
- if (style.eastAsiaFontFamily && !style.letterSpacing && hasCjk(text)) {
960
- const eaFallback = resolveFontFamily(style.eastAsiaFontFamily).cssFallback;
984
+ const perScriptFallback = {
985
+ ...style.eastAsiaFontFamily ? { [SCRIPT_CLASS.eastAsia]: resolveFontFamily(style.eastAsiaFontFamily).cssFallback } : {},
986
+ ...style.complexScriptFontFamily ? { [SCRIPT_CLASS.complex]: resolveFontFamily(style.complexScriptFontFamily).cssFallback } : {}
987
+ };
988
+ if (!style.letterSpacing && (style.eastAsiaFontFamily && hasCjk(text) || style.complexScriptFontFamily && hasComplexScript(text))) {
961
989
  let segmentedWidth = 0;
962
990
  for (const segment of segmentByScript(text)) {
963
- ctx.font = `${fontPrefix} ${segment.isCjk ? eaFallback : cssFallback}`;
991
+ ctx.font = `${fontPrefix} ${perScriptFallback[segment.script] ?? cssFallback}`;
964
992
  segmentedWidth += ctx.measureText(segment.text).width;
965
993
  }
966
994
  return segmentedWidth;
@@ -985,10 +1013,24 @@ function renderLine(block, line, alignment, doc, options) {
985
1013
  lineEl.style.boxSizing = "content-box";
986
1014
  lineEl.style.height = `${line.lineHeight}px`;
987
1015
  lineEl.style.lineHeight = `${line.lineHeight}px`;
1016
+ lineEl.dataset["measuredWidth"] = String(line.width);
988
1017
  const splitRuns = splitTextRunsByEastAsia(sliceRunsForLine(block, line));
989
1018
  const { runs: runsForLine, collapsedLeadingRuns: collapsedLeadingSpaceRuns, collapsedTrailingRuns: collapsedTrailingSpaceRuns } = splitCollapsibleLineEdgeSpaces(splitRuns, startsAfterSoftWrap(block, line));
990
1019
  const isCollapsedLineEdgeSpaceRun = (run) => collapsedLeadingSpaceRuns.has(run) || collapsedTrailingSpaceRuns.has(run);
991
1020
  const collapsedSpaceMeasureText = collapsedLeadingSpaceRuns.size > 0 || collapsedTrailingSpaceRuns.size > 0 ? createTextMeasurer(doc) : void 0;
1021
+ const applyJoinerRunStyles = (element, run) => {
1022
+ if (isTextRun(run) || isTabRun(run)) applyRunStyles(element, run);
1023
+ };
1024
+ const cursiveJoinerPlan = planCursiveJoiners({
1025
+ runs: runsForLine,
1026
+ textOf: (run) => isTextRun(run) ? toPaintedText(run.text) : void 0,
1027
+ applyRunStyles: applyJoinerRunStyles,
1028
+ doc
1029
+ });
1030
+ const withJoiners = (runEl, run) => {
1031
+ if (lineEl.style.display === "flex") return [runEl];
1032
+ return withCursiveJoiners(runEl, run, cursiveJoinerPlan, applyJoinerRunStyles, doc);
1033
+ };
992
1034
  const renderLineTextRun = (run) => {
993
1035
  const runEl = renderTextRun(run, doc);
994
1036
  if (collapsedLeadingSpaceRuns.has(run)) runEl.dataset["collapsedLeadingSpaces"] = "true";
@@ -1113,7 +1155,7 @@ function renderLine(block, line, alignment, doc, options) {
1113
1155
  for (let j = i + 1; j < runsForLine.length; j++) {
1114
1156
  const next = runsForLine[j];
1115
1157
  if (isTabRun(next) || isLineBreakRun(next)) break;
1116
- if (isTextRun(next)) lineEl.append(renderLineTextRun(next));
1158
+ if (isTextRun(next)) lineEl.append(...withJoiners(renderLineTextRun(next), next));
1117
1159
  else if (isFieldRun(next) && options?.context) lineEl.append(renderFieldRun(next, doc, options.context));
1118
1160
  else if (isImageRun(next)) {
1119
1161
  if (isFloatingImageRun(next)) continue;
@@ -1133,7 +1175,7 @@ function renderLine(block, line, alignment, doc, options) {
1133
1175
  currentX += tabWidth;
1134
1176
  } else if (isTextRun(run)) {
1135
1177
  const runEl = renderLineTextRun(run);
1136
- lineEl.append(runEl);
1178
+ lineEl.append(...withJoiners(runEl, run));
1137
1179
  if (isCollapsedLineEdgeSpaceRun(run)) continue;
1138
1180
  if (!measureText) continue;
1139
1181
  const fontSize = run.fontSize || 11;