@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.
- package/dist/ai-edits/headless.js +45 -33
- package/dist/controller/fontReadiness.js +2 -0
- package/dist/docx/server/extractDocxText.d.ts +43 -1
- package/dist/docx/server/extractDocxText.js +297 -35
- package/dist/docx/xmlParser.d.ts +14 -1
- package/dist/docx/xmlParser.js +50 -3
- package/dist/layout-bridge/convert/toFlowBlocks.js +7 -0
- package/dist/layout-engine/measure/cache.js +1 -1
- package/dist/layout-engine/measure/measureContainer.js +25 -7
- package/dist/layout-engine/measure/measureHelpers.js +1 -0
- package/dist/layout-engine/measure/measureTypes.d.ts +5 -0
- package/dist/layout-engine/types.d.ts +9 -0
- package/dist/layout-painter/cursiveJoiners.d.ts +80 -0
- package/dist/layout-painter/cursiveJoiners.js +199 -0
- package/dist/layout-painter/renderPage.js +1 -0
- package/dist/layout-painter/renderParagraph.js +52 -10
- package/dist/server.d.ts +2 -2
- package/dist/utils/cursiveJoining.d.ts +49 -0
- package/dist/utils/cursiveJoining.js +97 -0
- package/dist/utils/joiningTypes.gen.d.ts +26 -0
- package/dist/utils/joiningTypes.gen.js +1136 -0
- package/dist/utils/scriptSegments.d.ts +36 -5
- package/dist/utils/scriptSegments.js +59 -8
- package/package.json +2 -2
package/dist/docx/xmlParser.js
CHANGED
|
@@ -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,
|
|
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 (
|
|
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
|
|
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 (
|
|
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.
|
|
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) ||
|
|
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.
|
|
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
|
-
|
|
960
|
-
|
|
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.
|
|
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;
|