@stll/folio-core 0.16.0 → 0.17.1

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.
@@ -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";
@@ -1032,10 +1074,11 @@ function renderLine(block, line, alignment, doc, options) {
1032
1074
  if (alignment === "justify" && options) {
1033
1075
  if (!options.isLastLine || options.paragraphEndsWithLineBreak) {
1034
1076
  const firstLineIndentPx = options.isFirstLine ? options.firstLineIndentPx ?? 0 : 0;
1077
+ const firstLinePositiveIndentPx = Math.max(0, firstLineIndentPx);
1035
1078
  const firstLineHangingPx = Math.max(0, -firstLineIndentPx);
1036
1079
  const hasVisibleListMarker = options.isFirstLine && block.attrs?.listMarker && !block.attrs.listMarkerHidden;
1037
1080
  const firstLineHangingExpansionPx = hasVisibleListMarker ? Math.min(firstLineHangingPx, Math.max(0, options.leftIndentPx ?? 0)) : firstLineHangingPx;
1038
- const justifyCapacityPx = options.availableWidth + firstLineHangingExpansionPx;
1081
+ const justifyCapacityPx = options.availableWidth - firstLinePositiveIndentPx + firstLineHangingExpansionPx;
1039
1082
  const overfullPx = line.width - justifyCapacityPx;
1040
1083
  const shrinkableSpaces = countShrinkableSpaces(runsForLine.filter((run) => !isTextRun(run) || !isCollapsedLineEdgeSpaceRun(run)), options.context);
1041
1084
  if (overfullPx > RIGHT_EDGE_EPSILON_PX && shrinkableSpaces > 0) {
@@ -1113,7 +1156,7 @@ function renderLine(block, line, alignment, doc, options) {
1113
1156
  for (let j = i + 1; j < runsForLine.length; j++) {
1114
1157
  const next = runsForLine[j];
1115
1158
  if (isTabRun(next) || isLineBreakRun(next)) break;
1116
- if (isTextRun(next)) lineEl.append(renderLineTextRun(next));
1159
+ if (isTextRun(next)) lineEl.append(...withJoiners(renderLineTextRun(next), next));
1117
1160
  else if (isFieldRun(next) && options?.context) lineEl.append(renderFieldRun(next, doc, options.context));
1118
1161
  else if (isImageRun(next)) {
1119
1162
  if (isFloatingImageRun(next)) continue;
@@ -1133,7 +1176,7 @@ function renderLine(block, line, alignment, doc, options) {
1133
1176
  currentX += tabWidth;
1134
1177
  } else if (isTextRun(run)) {
1135
1178
  const runEl = renderLineTextRun(run);
1136
- lineEl.append(runEl);
1179
+ lineEl.append(...withJoiners(runEl, run));
1137
1180
  if (isCollapsedLineEdgeSpaceRun(run)) continue;
1138
1181
  if (!measureText) continue;
1139
1182
  const fontSize = run.fontSize || 11;
package/dist/server.d.ts CHANGED
@@ -18,8 +18,8 @@ import { EvaluateDocxXmlPatchProposalArgs, FOLIO_DOCX_XML_PATCH_PROPOSAL_DEFAULT
18
18
  import { FOLIO_DOCX_CONFORMANCE_CHECKS, FOLIO_DOCX_CONFORMANCE_ISSUE_CODES, FOLIO_DOCX_CONFORMANCE_PROFILE, FOLIO_DOCX_CONFORMANCE_REPORT_VERSION, FolioDocxConformanceCheck, FolioDocxConformanceCheckId, FolioDocxConformanceCheckStatus, FolioDocxConformanceIssue, FolioDocxConformanceIssueCode, FolioDocxConformanceReport, FolioDocxConformanceStatus, ValidateDocxConformanceOptions, validateDocxConformance } from "./docx/server/validateDocxConformance.js";
19
19
  import { ApplyDocxXmlPatchProposalArgs, FOLIO_DOCX_XML_PATCH_APPLICATION_PROFILE, FOLIO_DOCX_XML_PATCH_APPLICATION_VERSION, FolioDocxXmlPatchApplication, FolioDocxXmlPatchApplicationError, FolioDocxXmlPatchApplicationReceipt, UnsupportedFolioDocxXmlPatchApplicationProfileError, applyDocxXmlPatchProposal } from "./docx/server/applyDocxXmlPatchProposal.js";
20
20
  import { docxToMarkdown } from "./docx/server/docxToMarkdown.js";
21
- import { DocxParagraphSource, DocxTableRowKind, DocxTableRowPosition, ExtractedDocxParagraph, ExtractedDocxText, extractDocxText } from "./docx/server/extractDocxText.js";
21
+ import { DocxParagraphSource, DocxTableRowKind, DocxTableRowPosition, ExtractedDocxParagraph, ExtractedDocxTableCell, ExtractedDocxTableCellParagraph, ExtractedDocxText, extractDocxText } from "./docx/server/extractDocxText.js";
22
22
  import { FOLIO_DOCX_PACKAGE_INSPECTION_DEFAULTS, FOLIO_DOCX_PACKAGE_INSPECTION_ERROR_CODES, FOLIO_DOCX_PACKAGE_INSPECTION_VERSION, FolioDocxInspectedXmlPart, FolioDocxPackageInspection, FolioDocxPackageInspectionError, FolioDocxPackageInspectionErrorCode, FolioDocxPackageInspectionLimits, FolioDocxPackagePart, FolioDocxPackagePartKind, InspectDocxPackageOptions, inspectDocxPackage } from "./docx/server/inspectDocxPackage.js";
23
23
  import { GenerateRedlineDocxOptions, GenerateRedlineDocxResult, GenerateRedlineUnprocessedStory, InvalidGenerateRedlineDocxOptionsError, generateRedlineDocx } from "./redline.js";
24
24
  import { FOLIO_VERSION_COMPARISON_PRIVACY_TRANSFORMS, FOLIO_VERSION_COMPARISON_SCOPES, FolioBlockDiff, FolioCompareDocxVersionsOptions, FolioDocumentMetadataValue, FolioFormatProperty, FolioMetadataDiff, FolioStoryDiff, FolioVersionBlockHandle, FolioVersionComparisonPrivacyTransform, FolioVersionComparisonScope, FolioVersionDiff, FolioVersionDiffPrivacyOptions, FolioVersionDiffPrivacyReport, FolioVersionDiffSegment, FolioVersionDiffSummaryCounts, InvalidFolioVersionComparisonOptionsError, applyFolioVersionDiffPrivacy, compareDocxVersions, isFolioVersionComparisonPrivacyTransform, isFolioVersionComparisonScope } from "./version-comparison.js";
25
- export { type ApplyDocxXmlPatchProposalArgs, type ApplyFolioAIEditsToBufferOptions, type ApplyFolioAIEditsToBufferResult, type CreateCommentReplyInput, type CreateEmptyDocumentOptions, DOCUMENT_PRESET_VERSION, DOCUMENT_STYLE_SET_VERSION, type DeriveBlockIdInput, type DocumentPreset, type DocumentStyleCatalog, type DocumentStyleCatalogEntry, type DocumentStyleSet, DocxArchiveError, type DocxArchiveOptions, type DocxParagraphSource, type DocxTableRowKind, type DocxTableRowPosition, EnsureParaIdsError, type EnsureParaIdsOptions, type EnsureParaIdsResult, type EvaluateDocxXmlPatchProposalArgs, type ExtractDocumentStyleSetOptions, type ExtractedDocxParagraph, type ExtractedDocxText, FOLIO_DOCUMENT_METADATA_PROPERTIES, FOLIO_DOCUMENT_OPERATION_BATCH_MODES, FOLIO_DOCUMENT_OPERATION_CONTRACT_VERSION, FOLIO_DOCUMENT_OPERATION_MODES, FOLIO_DOCUMENT_OPERATION_MODES_BY_TYPE, FOLIO_DOCUMENT_OPERATION_PRECONDITIONS, FOLIO_DOCUMENT_OPERATION_STORIES, FOLIO_DOCUMENT_OPERATION_TYPES, FOLIO_DOCUMENT_PRIVACY_TRANSFORMS, FOLIO_DOCX_CONFORMANCE_CHECKS, FOLIO_DOCX_CONFORMANCE_ISSUE_CODES, FOLIO_DOCX_CONFORMANCE_PROFILE, FOLIO_DOCX_CONFORMANCE_REPORT_VERSION, FOLIO_DOCX_PACKAGE_INSPECTION_DEFAULTS, FOLIO_DOCX_PACKAGE_INSPECTION_ERROR_CODES, FOLIO_DOCX_PACKAGE_INSPECTION_VERSION, FOLIO_DOCX_XML_PATCH_APPLICATION_PROFILE, FOLIO_DOCX_XML_PATCH_APPLICATION_VERSION, FOLIO_DOCX_XML_PATCH_PROPOSAL_DEFAULTS, FOLIO_DOCX_XML_PATCH_PROPOSAL_ISSUE_CODES, FOLIO_DOCX_XML_PATCH_PROPOSAL_PROFILE, FOLIO_DOCX_XML_PATCH_PROPOSAL_VERSION, FOLIO_RESOLVED_REVIEWED_VIEWS, FOLIO_REVIEWED_VIEWS, FOLIO_VERSION_COMPARISON_PRIVACY_TRANSFORMS, FOLIO_VERSION_COMPARISON_SCOPES, type FolioAIBlock, type FolioAIBlockAnchor, type FolioAIBlockKind, type FolioAIBlockPreviewRun, type FolioAIComment, type FolioAIEditApplyMode, type FolioAIEditApplyResult, type FolioAIEditOperation, type FolioAIEditPrecondition, type FolioAIEditSnapshot, type FolioAIInlineFormatting, type FolioAITextRangeHandle, type FolioApplyDocumentOperationsOptions, type FolioApplyDocumentOperationsToStoryOptions, type FolioApplyOperationsOptions, type FolioBlockDiff, type FolioBlockId, type FolioCompareDocxVersionsOptions, type FolioDocumentMetadataProperty, type FolioDocumentMetadataValue, type FolioDocumentNavigationTarget, type FolioDocumentOperation, type FolioDocumentOperationAffectedTarget, type FolioDocumentOperationBatch, type FolioDocumentOperationCapabilities, type FolioDocumentOperationIssue, type FolioDocumentOperationMode, type FolioDocumentOperationPrecondition, type FolioDocumentOperationReceipt, type FolioDocumentOperationRecovery, type FolioDocumentOperationResult, type FolioDocumentOperationStatus, type FolioDocumentOperationStory, type FolioDocumentOperationType, type FolioDocumentOperationUndoFailureReason, type FolioDocumentOperationUndoHandle, type FolioDocumentOperationUndoResult, type FolioDocumentOutline, type FolioDocumentOutlineEntry, FolioDocumentPrivacyArchiveError, type FolioDocumentPrivacyOptions, type FolioDocumentPrivacyReport, type FolioDocumentPrivacyTransform, type FolioDocumentSection, type FolioDocumentSectionHandle, type FolioDocumentSectionReadResult, type FolioDocumentStory, type FolioDocumentStoryHandle, FolioDocumentStoryNotFoundError, type FolioDocxConformanceCheck, type FolioDocxConformanceCheckId, type FolioDocxConformanceCheckStatus, type FolioDocxConformanceIssue, type FolioDocxConformanceIssueCode, type FolioDocxConformanceReport, type FolioDocxConformanceStatus, type FolioDocxInspectedXmlPart, type FolioDocxPackageInspection, FolioDocxPackageInspectionError, type FolioDocxPackageInspectionErrorCode, type FolioDocxPackageInspectionLimits, type FolioDocxPackagePart, type FolioDocxPackagePartKind, type FolioDocxPreparedXmlReplacement, FolioDocxReviewer, type FolioDocxReviewerOptions, type FolioDocxXmlPatchApplication, FolioDocxXmlPatchApplicationError, type FolioDocxXmlPatchApplicationReceipt, type FolioDocxXmlPatchProposal, type FolioDocxXmlPatchProposalEvaluation, type FolioDocxXmlPatchProposalIssue, type FolioDocxXmlPatchProposalIssueCode, type FolioDocxXmlPatchProposalLimits, type FolioDocxXmlReplacement, type FolioEditableDocumentStoryHandle, type FolioFormatProperty, type FolioMetadataDiff, type FolioReadReviewedStoryOptions, type FolioResolveReviewedStoryOptions, type FolioResolvedReviewedView, type FolioReviewChange, type FolioReviewChangeFilter, type FolioReviewChangeKind, type FolioReviewComment, type FolioReviewCommentFilter, type FolioReviewCommentReply, type FolioReviewReplyInput, type FolioReviewedStory, type FolioReviewedView, type FolioStoryDiff, type FolioVersionBlockHandle, type FolioVersionComparisonPrivacyTransform, type FolioVersionComparisonScope, type FolioVersionDiff, type FolioVersionDiffPrivacyOptions, type FolioVersionDiffPrivacyReport, type FolioVersionDiffSegment, type FolioVersionDiffSummaryCounts, type GenerateRedlineDocxOptions, type GenerateRedlineDocxResult, type GenerateRedlineUnprocessedStory, type InspectDocxPackageOptions, InvalidFolioDocumentOperationBatchError, InvalidFolioDocumentPrivacyOptionsError, InvalidFolioDocxXmlPatchProposalError, InvalidFolioDocxXmlPatchProposalOptionsError, InvalidFolioVersionComparisonOptionsError, InvalidGenerateRedlineDocxOptionsError, type RewriteDocxMetadataPrivacyResult, STELLA_STYLE_SET_NAME, UnsupportedFolioDocumentOperationVersionError, UnsupportedFolioDocxXmlPatchApplicationProfileError, UnsupportedFolioReviewedViewError, type ValidateDocxConformanceOptions, applyDocxXmlPatchProposal, applyFolioAIEditsToBuffer, applyFolioVersionDiffPrivacy, assertSupportedFolioDocumentOperationVersion, compareDocxVersions, createDocx, createEmptyDocument, createFolioAITextRangeHandle, createStellaStyleDocumentPreset, createStellaStyleSet, deriveBlockId, docxToMarkdown, ensureParaIds, evaluateDocxXmlPatchProposal, extractDocumentStyleSet, extractDocumentStyleSetFromDocx, extractDocxText, generateRedlineDocx, getFolioDocumentOperationCapabilities, getFolioDocumentOperationIssues, getFolioDocumentOperationReceipts, getFolioDocumentOutline, getFolioParaIdFromBlockId, hashFolioAIBlockText, inspectDocumentStyles, inspectDocumentStylesFromDocx, inspectDocxPackage, isFolioBlockId, isFolioDocumentOperationModeSupported, isFolioDocumentPrivacyTransform, isFolioResolvedReviewedView, isFolioReviewedView, isFolioVersionComparisonPrivacyTransform, isFolioVersionComparisonScope, isSequentialFolioBlockId, isSupportedFolioDocumentOperationVersion, normalizeFolioAIBlockText, parseFolioDocumentOperationBatch, parseFolioDocxXmlPatchProposal, readFolioDocumentSection, replyToComment, rewriteDocxMetadataPrivacy, validateDocxConformance };
25
+ export { type ApplyDocxXmlPatchProposalArgs, type ApplyFolioAIEditsToBufferOptions, type ApplyFolioAIEditsToBufferResult, type CreateCommentReplyInput, type CreateEmptyDocumentOptions, DOCUMENT_PRESET_VERSION, DOCUMENT_STYLE_SET_VERSION, type DeriveBlockIdInput, type DocumentPreset, type DocumentStyleCatalog, type DocumentStyleCatalogEntry, type DocumentStyleSet, DocxArchiveError, type DocxArchiveOptions, type DocxParagraphSource, type DocxTableRowKind, type DocxTableRowPosition, EnsureParaIdsError, type EnsureParaIdsOptions, type EnsureParaIdsResult, type EvaluateDocxXmlPatchProposalArgs, type ExtractDocumentStyleSetOptions, type ExtractedDocxParagraph, type ExtractedDocxTableCell, type ExtractedDocxTableCellParagraph, type ExtractedDocxText, FOLIO_DOCUMENT_METADATA_PROPERTIES, FOLIO_DOCUMENT_OPERATION_BATCH_MODES, FOLIO_DOCUMENT_OPERATION_CONTRACT_VERSION, FOLIO_DOCUMENT_OPERATION_MODES, FOLIO_DOCUMENT_OPERATION_MODES_BY_TYPE, FOLIO_DOCUMENT_OPERATION_PRECONDITIONS, FOLIO_DOCUMENT_OPERATION_STORIES, FOLIO_DOCUMENT_OPERATION_TYPES, FOLIO_DOCUMENT_PRIVACY_TRANSFORMS, FOLIO_DOCX_CONFORMANCE_CHECKS, FOLIO_DOCX_CONFORMANCE_ISSUE_CODES, FOLIO_DOCX_CONFORMANCE_PROFILE, FOLIO_DOCX_CONFORMANCE_REPORT_VERSION, FOLIO_DOCX_PACKAGE_INSPECTION_DEFAULTS, FOLIO_DOCX_PACKAGE_INSPECTION_ERROR_CODES, FOLIO_DOCX_PACKAGE_INSPECTION_VERSION, FOLIO_DOCX_XML_PATCH_APPLICATION_PROFILE, FOLIO_DOCX_XML_PATCH_APPLICATION_VERSION, FOLIO_DOCX_XML_PATCH_PROPOSAL_DEFAULTS, FOLIO_DOCX_XML_PATCH_PROPOSAL_ISSUE_CODES, FOLIO_DOCX_XML_PATCH_PROPOSAL_PROFILE, FOLIO_DOCX_XML_PATCH_PROPOSAL_VERSION, FOLIO_RESOLVED_REVIEWED_VIEWS, FOLIO_REVIEWED_VIEWS, FOLIO_VERSION_COMPARISON_PRIVACY_TRANSFORMS, FOLIO_VERSION_COMPARISON_SCOPES, type FolioAIBlock, type FolioAIBlockAnchor, type FolioAIBlockKind, type FolioAIBlockPreviewRun, type FolioAIComment, type FolioAIEditApplyMode, type FolioAIEditApplyResult, type FolioAIEditOperation, type FolioAIEditPrecondition, type FolioAIEditSnapshot, type FolioAIInlineFormatting, type FolioAITextRangeHandle, type FolioApplyDocumentOperationsOptions, type FolioApplyDocumentOperationsToStoryOptions, type FolioApplyOperationsOptions, type FolioBlockDiff, type FolioBlockId, type FolioCompareDocxVersionsOptions, type FolioDocumentMetadataProperty, type FolioDocumentMetadataValue, type FolioDocumentNavigationTarget, type FolioDocumentOperation, type FolioDocumentOperationAffectedTarget, type FolioDocumentOperationBatch, type FolioDocumentOperationCapabilities, type FolioDocumentOperationIssue, type FolioDocumentOperationMode, type FolioDocumentOperationPrecondition, type FolioDocumentOperationReceipt, type FolioDocumentOperationRecovery, type FolioDocumentOperationResult, type FolioDocumentOperationStatus, type FolioDocumentOperationStory, type FolioDocumentOperationType, type FolioDocumentOperationUndoFailureReason, type FolioDocumentOperationUndoHandle, type FolioDocumentOperationUndoResult, type FolioDocumentOutline, type FolioDocumentOutlineEntry, FolioDocumentPrivacyArchiveError, type FolioDocumentPrivacyOptions, type FolioDocumentPrivacyReport, type FolioDocumentPrivacyTransform, type FolioDocumentSection, type FolioDocumentSectionHandle, type FolioDocumentSectionReadResult, type FolioDocumentStory, type FolioDocumentStoryHandle, FolioDocumentStoryNotFoundError, type FolioDocxConformanceCheck, type FolioDocxConformanceCheckId, type FolioDocxConformanceCheckStatus, type FolioDocxConformanceIssue, type FolioDocxConformanceIssueCode, type FolioDocxConformanceReport, type FolioDocxConformanceStatus, type FolioDocxInspectedXmlPart, type FolioDocxPackageInspection, FolioDocxPackageInspectionError, type FolioDocxPackageInspectionErrorCode, type FolioDocxPackageInspectionLimits, type FolioDocxPackagePart, type FolioDocxPackagePartKind, type FolioDocxPreparedXmlReplacement, FolioDocxReviewer, type FolioDocxReviewerOptions, type FolioDocxXmlPatchApplication, FolioDocxXmlPatchApplicationError, type FolioDocxXmlPatchApplicationReceipt, type FolioDocxXmlPatchProposal, type FolioDocxXmlPatchProposalEvaluation, type FolioDocxXmlPatchProposalIssue, type FolioDocxXmlPatchProposalIssueCode, type FolioDocxXmlPatchProposalLimits, type FolioDocxXmlReplacement, type FolioEditableDocumentStoryHandle, type FolioFormatProperty, type FolioMetadataDiff, type FolioReadReviewedStoryOptions, type FolioResolveReviewedStoryOptions, type FolioResolvedReviewedView, type FolioReviewChange, type FolioReviewChangeFilter, type FolioReviewChangeKind, type FolioReviewComment, type FolioReviewCommentFilter, type FolioReviewCommentReply, type FolioReviewReplyInput, type FolioReviewedStory, type FolioReviewedView, type FolioStoryDiff, type FolioVersionBlockHandle, type FolioVersionComparisonPrivacyTransform, type FolioVersionComparisonScope, type FolioVersionDiff, type FolioVersionDiffPrivacyOptions, type FolioVersionDiffPrivacyReport, type FolioVersionDiffSegment, type FolioVersionDiffSummaryCounts, type GenerateRedlineDocxOptions, type GenerateRedlineDocxResult, type GenerateRedlineUnprocessedStory, type InspectDocxPackageOptions, InvalidFolioDocumentOperationBatchError, InvalidFolioDocumentPrivacyOptionsError, InvalidFolioDocxXmlPatchProposalError, InvalidFolioDocxXmlPatchProposalOptionsError, InvalidFolioVersionComparisonOptionsError, InvalidGenerateRedlineDocxOptionsError, type RewriteDocxMetadataPrivacyResult, STELLA_STYLE_SET_NAME, UnsupportedFolioDocumentOperationVersionError, UnsupportedFolioDocxXmlPatchApplicationProfileError, UnsupportedFolioReviewedViewError, type ValidateDocxConformanceOptions, applyDocxXmlPatchProposal, applyFolioAIEditsToBuffer, applyFolioVersionDiffPrivacy, assertSupportedFolioDocumentOperationVersion, compareDocxVersions, createDocx, createEmptyDocument, createFolioAITextRangeHandle, createStellaStyleDocumentPreset, createStellaStyleSet, deriveBlockId, docxToMarkdown, ensureParaIds, evaluateDocxXmlPatchProposal, extractDocumentStyleSet, extractDocumentStyleSetFromDocx, extractDocxText, generateRedlineDocx, getFolioDocumentOperationCapabilities, getFolioDocumentOperationIssues, getFolioDocumentOperationReceipts, getFolioDocumentOutline, getFolioParaIdFromBlockId, hashFolioAIBlockText, inspectDocumentStyles, inspectDocumentStylesFromDocx, inspectDocxPackage, isFolioBlockId, isFolioDocumentOperationModeSupported, isFolioDocumentPrivacyTransform, isFolioResolvedReviewedView, isFolioReviewedView, isFolioVersionComparisonPrivacyTransform, isFolioVersionComparisonScope, isSequentialFolioBlockId, isSupportedFolioDocumentOperationVersion, normalizeFolioAIBlockText, parseFolioDocumentOperationBatch, parseFolioDocxXmlPatchProposal, readFolioDocumentSection, replyToComment, rewriteDocxMetadataPrivacy, validateDocxConformance };
@@ -0,0 +1,49 @@
1
+ //#region src/utils/cursiveJoining.d.ts
2
+ /**
3
+ * Cursive-joining queries for scripts whose letters change shape by position.
4
+ *
5
+ * In Arabic, Syriac, N'Ko, Adlam and friends a letter's glyph depends on its
6
+ * neighbours: isolated, initial, medial or final. Shaping stops at an element
7
+ * boundary whenever the two sides resolve to a different font face, so a run
8
+ * split inside a word — bold on one letter, a different size, a different
9
+ * family — makes the word come apart visually, while Word joins straight
10
+ * through it. These predicates say whether a given split severed a connection,
11
+ * so the painter and the measurer can repair it identically.
12
+ *
13
+ * A boundary where both sides share a face is NOT a problem: browsers shape
14
+ * across inline boxes when no shaping-relevant property changes, so ordinary
15
+ * colour or underline marks (tracked changes, comment anchors) already join.
16
+ *
17
+ * The tables come from the pinned UCD via `scripts/generate-joining-types.ts`;
18
+ * see {@link joiningTypes.gen} for why they are generated rather than authored.
19
+ */
20
+ /** Joining_Type D, L or C: this code point can connect to the one after it. */
21
+ declare const joinsToFollowing: (cp: number) => boolean;
22
+ /** Joining_Type D, R or C: this code point can connect to the one before it. */
23
+ declare const joinsToPreceding: (cp: number) => boolean;
24
+ /**
25
+ * Joining_Type T: invisible to joining. Combining marks (harakat, Syriac
26
+ * vowels) sit between two letters without breaking their connection, so they
27
+ * must be skipped when looking for a joining neighbour.
28
+ */
29
+ declare const isJoiningTransparent: (cp: number) => boolean;
30
+ /** Joining_Type D, L or R: a cursive letter, as opposed to ZWJ or punctuation. */
31
+ declare const isCursiveLetter: (cp: number) => boolean;
32
+ /**
33
+ * Whether the text contains any cursive letter at all.
34
+ *
35
+ * Callers gate on this so the all-Latin hot path never pays for joining
36
+ * analysis, mirroring how `hasCjk` gates script segmentation.
37
+ */
38
+ declare function hasCursiveLetter(text: string): boolean;
39
+ /**
40
+ * Whether `before` and `after` were cursively connected before something split
41
+ * them apart.
42
+ *
43
+ * True means the two sides need a joiner to keep rendering as one word. False
44
+ * means the split is harmless: a space, a non-joining letter such as alef on
45
+ * the left of the cut, punctuation, or a script that does not join at all.
46
+ */
47
+ declare function joinsAcrossBoundary(before: string, after: string): boolean;
48
+ //#endregion
49
+ export { hasCursiveLetter, isCursiveLetter, isJoiningTransparent, joinsAcrossBoundary, joinsToFollowing, joinsToPreceding };
@@ -0,0 +1,97 @@
1
+ import { JOINING_LETTER_RANGES, JOINING_TRANSPARENT_RANGES, JOINS_BACKWARD_RANGES, JOINS_FORWARD_RANGES } from "./joiningTypes.gen.js";
2
+ //#region src/utils/cursiveJoining.ts
3
+ /**
4
+ * Cursive-joining queries for scripts whose letters change shape by position.
5
+ *
6
+ * In Arabic, Syriac, N'Ko, Adlam and friends a letter's glyph depends on its
7
+ * neighbours: isolated, initial, medial or final. Shaping stops at an element
8
+ * boundary whenever the two sides resolve to a different font face, so a run
9
+ * split inside a word — bold on one letter, a different size, a different
10
+ * family — makes the word come apart visually, while Word joins straight
11
+ * through it. These predicates say whether a given split severed a connection,
12
+ * so the painter and the measurer can repair it identically.
13
+ *
14
+ * A boundary where both sides share a face is NOT a problem: browsers shape
15
+ * across inline boxes when no shaping-relevant property changes, so ordinary
16
+ * colour or underline marks (tracked changes, comment anchors) already join.
17
+ *
18
+ * The tables come from the pinned UCD via `scripts/generate-joining-types.ts`;
19
+ * see {@link joiningTypes.gen} for why they are generated rather than authored.
20
+ */
21
+ /**
22
+ * Membership test over a flat sorted `[start, end, …]` inclusive-range list.
23
+ *
24
+ * Binary search rather than `.some()`: the transparent table alone is 375
25
+ * ranges, and these run per code point on every measured word.
26
+ */
27
+ function inRanges(ranges, cp) {
28
+ let low = 0;
29
+ let high = ranges.length / 2 - 1;
30
+ while (low <= high) {
31
+ const mid = low + high >> 1;
32
+ const start = ranges[mid * 2];
33
+ const end = ranges[mid * 2 + 1];
34
+ if (cp < start) high = mid - 1;
35
+ else if (cp > end) low = mid + 1;
36
+ else return true;
37
+ }
38
+ return false;
39
+ }
40
+ /** Joining_Type D, L or C: this code point can connect to the one after it. */
41
+ const joinsToFollowing = (cp) => inRanges(JOINS_FORWARD_RANGES, cp);
42
+ /** Joining_Type D, R or C: this code point can connect to the one before it. */
43
+ const joinsToPreceding = (cp) => inRanges(JOINS_BACKWARD_RANGES, cp);
44
+ /**
45
+ * Joining_Type T: invisible to joining. Combining marks (harakat, Syriac
46
+ * vowels) sit between two letters without breaking their connection, so they
47
+ * must be skipped when looking for a joining neighbour.
48
+ */
49
+ const isJoiningTransparent = (cp) => inRanges(JOINING_TRANSPARENT_RANGES, cp);
50
+ /** Joining_Type D, L or R: a cursive letter, as opposed to ZWJ or punctuation. */
51
+ const isCursiveLetter = (cp) => inRanges(JOINING_LETTER_RANGES, cp);
52
+ /**
53
+ * Whether the text contains any cursive letter at all.
54
+ *
55
+ * Callers gate on this so the all-Latin hot path never pays for joining
56
+ * analysis, mirroring how `hasCjk` gates script segmentation.
57
+ */
58
+ function hasCursiveLetter(text) {
59
+ for (const ch of text) if (isCursiveLetter(ch.codePointAt(0))) return true;
60
+ return false;
61
+ }
62
+ /** Last code point of `text` that joining can see, skipping transparents. */
63
+ function lastJoiningRelevantCodePoint(text) {
64
+ let index = text.length;
65
+ while (index > 0) {
66
+ const unit = text.charCodeAt(index - 1);
67
+ const isLowSurrogate = unit >= 56320 && unit <= 57343;
68
+ const high = isLowSurrogate ? text.charCodeAt(index - 2) : NaN;
69
+ const start = isLowSurrogate && high >= 55296 && high <= 56319 ? index - 2 : index - 1;
70
+ const cp = text.codePointAt(start);
71
+ if (!isJoiningTransparent(cp)) return cp;
72
+ index = start;
73
+ }
74
+ }
75
+ /** First code point of `text` that joining can see, skipping transparents. */
76
+ function firstJoiningRelevantCodePoint(text) {
77
+ for (const ch of text) {
78
+ const cp = ch.codePointAt(0);
79
+ if (!isJoiningTransparent(cp)) return cp;
80
+ }
81
+ }
82
+ /**
83
+ * Whether `before` and `after` were cursively connected before something split
84
+ * them apart.
85
+ *
86
+ * True means the two sides need a joiner to keep rendering as one word. False
87
+ * means the split is harmless: a space, a non-joining letter such as alef on
88
+ * the left of the cut, punctuation, or a script that does not join at all.
89
+ */
90
+ function joinsAcrossBoundary(before, after) {
91
+ const previous = lastJoiningRelevantCodePoint(before);
92
+ if (previous === void 0 || !joinsToFollowing(previous)) return false;
93
+ const next = firstJoiningRelevantCodePoint(after);
94
+ return next !== void 0 && joinsToPreceding(next);
95
+ }
96
+ //#endregion
97
+ export { hasCursiveLetter, isCursiveLetter, isJoiningTransparent, joinsAcrossBoundary, joinsToFollowing, joinsToPreceding };
@@ -0,0 +1,26 @@
1
+ //#region src/utils/joiningTypes.gen.d.ts
2
+ /**
3
+ * GENERATED FILE — do not edit.
4
+ *
5
+ * Unicode Joining_Type ranges, derived from the pinned UCD sources by
6
+ * `scripts/generate-joining-types.ts`. Regenerate with:
7
+ *
8
+ * bun run generate:joining-types
9
+ *
10
+ * ArabicShaping.txt: 17.0.0
11
+ * DerivedGeneralCategory.txt: 17.0.0
12
+ *
13
+ * Each table is a flat sorted `[start, end, …]` list of inclusive code-point
14
+ * ranges. Transparent is dominated by the General_Category Mn/Me/Cf default,
15
+ * which is why it is by far the largest table.
16
+ */
17
+ /** Joining_Type D, L or C: connects to the FOLLOWING character. */
18
+ declare const JOINS_FORWARD_RANGES: readonly number[];
19
+ /** Joining_Type D, R or C: connects to the PRECEDING character. */
20
+ declare const JOINS_BACKWARD_RANGES: readonly number[];
21
+ /** Joining_Type T: transparent, skipped when looking for a joining neighbour. */
22
+ declare const JOINING_TRANSPARENT_RANGES: readonly number[];
23
+ /** Joining_Type D, L or R: a cursive letter. Gates the whole joining path. */
24
+ declare const JOINING_LETTER_RANGES: readonly number[];
25
+ //#endregion
26
+ export { JOINING_LETTER_RANGES, JOINING_TRANSPARENT_RANGES, JOINS_BACKWARD_RANGES, JOINS_FORWARD_RANGES };