@stll/folio-core 0.48.0 → 0.49.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/dist/ai-edits/apply.js +565 -121
  2. package/dist/ai-edits/minimal-replacement.d.ts +64 -0
  3. package/dist/ai-edits/minimal-replacement.js +282 -0
  4. package/dist/controller/layoutPipeline.js +11 -1
  5. package/dist/display-list/build/paragraphPrimitives.js +63 -7
  6. package/dist/display-list/build/textBoxPrimitives.js +1 -1
  7. package/dist/display-list/build/unsupported.d.ts +2 -2
  8. package/dist/display-list/build/unsupported.js +0 -0
  9. package/dist/display-list/dom/renderDisplayListToDom.js +0 -1
  10. package/dist/docx/blockContentParser.js +2 -2
  11. package/dist/docx/bulletMarkers.d.ts +18 -2
  12. package/dist/docx/bulletMarkers.js +27 -2
  13. package/dist/docx/drawingGroupChildren.d.ts +60 -0
  14. package/dist/docx/drawingGroupChildren.js +176 -0
  15. package/dist/docx/drawingUtils.js +3 -0
  16. package/dist/docx/groupDrawingParser.d.ts +7 -1
  17. package/dist/docx/groupDrawingParser.js +123 -32
  18. package/dist/docx/packageParts.d.ts +45 -1
  19. package/dist/docx/packageParts.js +93 -2
  20. package/dist/docx/paraIdAttribute.d.ts +3 -2
  21. package/dist/docx/paragraphParser.js +91 -7
  22. package/dist/docx/paragraphTextBoxEnrichment.js +126 -4
  23. package/dist/docx/runParser.js +10 -0
  24. package/dist/docx/selectiveSaveFlags.d.ts +10 -3
  25. package/dist/docx/selectiveSaveFlags.js +1 -1
  26. package/dist/docx/selectiveXmlPatch.d.ts +56 -34
  27. package/dist/docx/selectiveXmlPatch.js +222 -115
  28. package/dist/docx/serializer/groupTextBoxWriteBack.d.ts +12 -0
  29. package/dist/docx/serializer/groupTextBoxWriteBack.js +101 -0
  30. package/dist/docx/serializer/paragraphSerializer.js +5 -4
  31. package/dist/docx/serializer/runSerializer.d.ts +5 -1
  32. package/dist/docx/serializer/runSerializer.js +4 -3
  33. package/dist/docx/shapeAlternateContent.d.ts +25 -0
  34. package/dist/docx/shapeAlternateContent.js +52 -0
  35. package/dist/docx/textBoxParser.d.ts +10 -2
  36. package/dist/docx/textBoxParser.js +9 -3
  37. package/dist/internal/headlessRevisionResolution.js +50 -13
  38. package/dist/layout-bridge/convert/toFlowBlocks.js +26 -4
  39. package/dist/layout-engine/index.d.ts +2 -2
  40. package/dist/layout-engine/index.js +33 -4
  41. package/dist/layout-engine/keep-together.d.ts +5 -2
  42. package/dist/layout-engine/keep-together.js +5 -1
  43. package/dist/layout-engine/measure/advanceComposition.js +49 -3
  44. package/dist/layout-engine/measure/measureBlocks.js +5 -3
  45. package/dist/layout-engine/measure/measureContainer.js +46 -3
  46. package/dist/layout-engine/measure/measureHelpers.js +13 -3
  47. package/dist/layout-engine/measure/measureParagraph.js +44 -81
  48. package/dist/layout-engine/measure/smallCapsCasing.d.ts +56 -0
  49. package/dist/layout-engine/measure/smallCapsCasing.js +88 -0
  50. package/dist/layout-engine/paginator.d.ts +2 -0
  51. package/dist/layout-engine/paginator.js +5 -1
  52. package/dist/layout-engine/paragraphSpacing.d.ts +4 -2
  53. package/dist/layout-engine/paragraphSpacing.js +2 -1
  54. package/dist/layout-engine/tableRowBreak.d.ts +8 -1
  55. package/dist/layout-engine/tableRowBreak.js +22 -1
  56. package/dist/layout-engine/types.d.ts +21 -2
  57. package/dist/layout-painter/renderParagraph.js +41 -4
  58. package/dist/layout-painter/renderTextBox.d.ts +12 -2
  59. package/dist/layout-painter/renderTextBox.js +26 -2
  60. package/dist/prosemirror/alternateContentAttrs.d.ts +9 -0
  61. package/dist/prosemirror/alternateContentAttrs.js +35 -0
  62. package/dist/prosemirror/attrs/index.js +90 -0
  63. package/dist/prosemirror/commands/comments.js +51 -41
  64. package/dist/prosemirror/contentControlRevisions.d.ts +43 -0
  65. package/dist/prosemirror/contentControlRevisions.js +84 -0
  66. package/dist/prosemirror/conversion/fromProseDoc.js +46 -22
  67. package/dist/prosemirror/conversion/toProseDoc.js +42 -19
  68. package/dist/prosemirror/extensions/features/AutoBidiDetectionExtension.d.ts +7 -7
  69. package/dist/prosemirror/extensions/features/AutoBidiDetectionExtension.js +7 -7
  70. package/dist/prosemirror/extensions/nodes/FieldExtension.js +6 -3
  71. package/dist/prosemirror/extensions/nodes/SdtExtension.js +9 -2
  72. package/dist/prosemirror/extensions/nodes/ShapeExtension.js +1 -0
  73. package/dist/prosemirror/extensions/nodes/TextBoxExtension.js +3 -0
  74. package/dist/prosemirror/listMarker.js +2 -2
  75. package/dist/prosemirror/paragraphDirection.d.ts +29 -2
  76. package/dist/prosemirror/paragraphDirection.js +21 -2
  77. package/dist/prosemirror/plugins/suggestionMode.js +37 -8
  78. package/dist/prosemirror/rejoinRunCarriers.d.ts +27 -0
  79. package/dist/prosemirror/rejoinRunCarriers.js +66 -0
  80. package/dist/prosemirror/runIdentityAcrossRevisions.d.ts +10 -0
  81. package/dist/prosemirror/runIdentityAcrossRevisions.js +90 -0
  82. package/dist/prosemirror/schema/nodes.d.ts +37 -5
  83. package/dist/types/content.d.ts +2 -2
  84. package/package.json +2 -2
  85. package/dist/layout-engine/justifiedLineFit.d.ts +0 -7
  86. package/dist/layout-engine/justifiedLineFit.js +0 -6
@@ -0,0 +1,64 @@
1
+ import { WordDiffSegment } from "./word-diff.js";
2
+ //#region src/ai-edits/minimal-replacement.d.ts
3
+ /** Replace `source.slice(start, end)` with `text`; offsets index the source span. */
4
+ type TextChange = {
5
+ start: number;
6
+ end: number;
7
+ text: string;
8
+ };
9
+ /** A clean-text span that can only be replaced whole (a field result). */
10
+ type AtomicTextSpan = {
11
+ offset: number;
12
+ length: number;
13
+ };
14
+ /** Common prefix length that never ends between the halves of a surrogate pair. */
15
+ declare const commonPrefixLength: (a: string, b: string) => number;
16
+ /** Common suffix length that never starts between the halves of a surrogate pair. */
17
+ declare const commonSuffixLength: (a: string, b: string) => number;
18
+ /**
19
+ * Edits beyond which a replacement is treated as one rewrite. Bounds the diff
20
+ * at O((N + M) * D) time and O(D^2) memory; a rewrite that large keeps
21
+ * nothing of its source worth aligning to anyway.
22
+ */
23
+ declare const MAX_TOKEN_EDITS = 1024;
24
+ /**
25
+ * The shortest edit script between two token sequences (Myers, 1986), as
26
+ * segments of joined token text, or `null` past `maxEdits` edits.
27
+ *
28
+ * Deliberately the shortest one. The redline diff in `word-diff.ts` gives up
29
+ * short matches between changes so a reader sees whole phrases replaced; an
30
+ * edit applied directly has no reader to serve, and every token it gives up is
31
+ * text rewritten with someone else's formatting.
32
+ */
33
+ declare const shortestTokenDiff: (before: readonly string[], after: readonly string[], maxEdits?: number) => WordDiffSegment[] | null;
34
+ /**
35
+ * The changes from `source` to `replacement`, in source order and disjoint.
36
+ *
37
+ * Words (with their leading whitespace, and edge punctuation on its own; see
38
+ * `tokenizeWords`) are aligned by the shortest edit script, and each changed
39
+ * stretch is then trimmed to the characters that actually differ, so
40
+ * appending a letter to a word that spans two runs leaves both runs alone. A
41
+ * rewrite past {@link MAX_TOKEN_EDITS}, or a diff that does not reconstruct
42
+ * both strings exactly (it never should), is one change over the whole span,
43
+ * trimmed the same way.
44
+ */
45
+ declare const planTextChanges: (source: string, replacement: string) => TextChange[];
46
+ /**
47
+ * The changes a diff's segments describe, in source order and disjoint: each
48
+ * run of `del` and `ins` segments between two `equal` ones is one change,
49
+ * exactly as wide as the segments say. A redline's segments come cut to the
50
+ * granularity its reader asked for, so they are not trimmed further.
51
+ */
52
+ declare const changesFromSegments: (segments: readonly WordDiffSegment[]) => TextChange[];
53
+ /**
54
+ * `changes` widened so none cuts into an atomic span, then merged where the
55
+ * widening made two overlap. A widened change re-emits the source text it
56
+ * absorbed, so applying the result still yields exactly the replacement.
57
+ *
58
+ * `spans` are in the same coordinates as the changes (the source span's).
59
+ */
60
+ declare const widenChangesToAtomicSpans: (source: string, changes: readonly TextChange[], spans: readonly AtomicTextSpan[]) => TextChange[];
61
+ /** `source` with `changes` applied; the invariant the planner owes. */
62
+ declare const applyTextChanges: (source: string, changes: readonly TextChange[]) => string;
63
+ //#endregion
64
+ export { AtomicTextSpan, MAX_TOKEN_EDITS, TextChange, applyTextChanges, changesFromSegments, commonPrefixLength, commonSuffixLength, planTextChanges, shortestTokenDiff, widenChangesToAtomicSpans };
@@ -0,0 +1,282 @@
1
+ import { tokenizeWords } from "./word-diff.js";
2
+ //#region src/ai-edits/minimal-replacement.ts
3
+ /**
4
+ * The smallest set of text changes that turns one clean-text span into another.
5
+ *
6
+ * A direct-mode replacement (`replaceInBlock`, `replaceRange`, `replaceBlock`)
7
+ * names the text it matched and the text it wants. Swapping the whole match
8
+ * gives every character of the result the formatting of the first one, drops
9
+ * the inline content controls, note references and fields the match crossed,
10
+ * and makes a one-character edit rewrite the paragraph. The edit is the
11
+ * difference between the two strings, so that is all the applier writes:
12
+ *
13
+ * - Text outside a change is not touched: its runs, marks and every inline node
14
+ * between its characters stay exactly as they were.
15
+ * - A change deletes only the characters it removes (text, tabs, breaks and
16
+ * whole field results). Non-text inline content between those characters —
17
+ * bookmarks, comment anchors, drawings, content-control boundaries, rendered
18
+ * page breaks — stays where it was; the new text is written where the first
19
+ * removed character stood and takes its formatting.
20
+ * - A pure insertion follows the character before it, with the formatting that
21
+ * character has, the way typed text does. At the start of the matched span
22
+ * there is no such character inside the match, so it takes the formatting of
23
+ * the first matched character. A note reference's number lends no
24
+ * formatting: prose beside it is not superscript.
25
+ * - An insertion at the edge of an inline content control (`w:sdt`), a link or
26
+ * a comment range stays inside it only when the whole match lies inside it.
27
+ * Matching a control's or a link's text edits it; appending to a paragraph
28
+ * that ends in a checkbox or a link writes after it.
29
+ * - A field result is one unit: a change that would cut into it is widened to
30
+ * the whole result, which the new text then replaces as plain text (a result
31
+ * cannot be partly edited; it is regenerated from the field code). A field
32
+ * whose displayed text the replacement keeps is never touched. A match that
33
+ * itself starts or ends inside a field result is refused as before
34
+ * (`unsupportedBlock`).
35
+ *
36
+ * A replacement carrying inline emphasis markup (`**bold**`) states its own
37
+ * formatting and still replaces the match whole in direct mode.
38
+ *
39
+ * Tracked-changes and suggested modes cut their changes from the redline diff
40
+ * instead ({@link changesFromSegments}: word or character granularity, as the
41
+ * caller asked), and map them onto the document by the same rules. A change
42
+ * marks exactly the characters it removes as a deletion, which keeps their
43
+ * runs, and writes its text as an insertion after them, formatted as the
44
+ * first removed character that is not a note reference; a pure insertion is
45
+ * placed and formatted as above. Nothing outside a change is wrapped in a
46
+ * revision.
47
+ *
48
+ * This module is the pure half: clean-text offsets in, changes out. The
49
+ * applier maps each change onto the document.
50
+ */
51
+ const isHighSurrogate = (code) => code >= 55296 && code <= 56319;
52
+ const isLowSurrogate = (code) => code >= 56320 && code <= 57343;
53
+ /** Common prefix length that never ends between the halves of a surrogate pair. */
54
+ const commonPrefixLength = (a, b) => {
55
+ const limit = Math.min(a.length, b.length);
56
+ let index = 0;
57
+ while (index < limit && a.charCodeAt(index) === b.charCodeAt(index)) index++;
58
+ return index > 0 && isHighSurrogate(a.charCodeAt(index - 1)) ? index - 1 : index;
59
+ };
60
+ /** Common suffix length that never starts between the halves of a surrogate pair. */
61
+ const commonSuffixLength = (a, b) => {
62
+ const limit = Math.min(a.length, b.length);
63
+ let length = 0;
64
+ while (length < limit && a.charCodeAt(a.length - 1 - length) === b.charCodeAt(b.length - 1 - length)) length++;
65
+ return length > 0 && isLowSurrogate(a.charCodeAt(a.length - length)) ? length - 1 : length;
66
+ };
67
+ /** `change` without the characters its deleted and inserted text share at either end. */
68
+ const trimChange = (source, change) => {
69
+ const removed = source.slice(change.start, change.end);
70
+ const prefix = commonPrefixLength(removed, change.text);
71
+ const suffix = commonSuffixLength(removed.slice(prefix), change.text.slice(prefix));
72
+ const trimmed = {
73
+ start: change.start + prefix,
74
+ end: change.end - suffix,
75
+ text: change.text.slice(prefix, change.text.length - suffix)
76
+ };
77
+ return trimmed.start === trimmed.end && trimmed.text.length === 0 ? null : trimmed;
78
+ };
79
+ const reconstructs = (segments, source, replacement) => {
80
+ let before = "";
81
+ let after = "";
82
+ for (const segment of segments) {
83
+ if (segment.type !== "ins") before += segment.text;
84
+ if (segment.type !== "del") after += segment.text;
85
+ }
86
+ return before === source && after === replacement;
87
+ };
88
+ /**
89
+ * Edits beyond which a replacement is treated as one rewrite. Bounds the diff
90
+ * at O((N + M) * D) time and O(D^2) memory; a rewrite that large keeps
91
+ * nothing of its source worth aligning to anyway.
92
+ */
93
+ const MAX_TOKEN_EDITS = 1024;
94
+ /**
95
+ * The shortest edit script between two token sequences (Myers, 1986), as
96
+ * segments of joined token text, or `null` past `maxEdits` edits.
97
+ *
98
+ * Deliberately the shortest one. The redline diff in `word-diff.ts` gives up
99
+ * short matches between changes so a reader sees whole phrases replaced; an
100
+ * edit applied directly has no reader to serve, and every token it gives up is
101
+ * text rewritten with someone else's formatting.
102
+ */
103
+ const shortestTokenDiff = (before, after, maxEdits = MAX_TOKEN_EDITS) => {
104
+ const n = before.length;
105
+ const m = after.length;
106
+ const limit = Math.min(n + m, maxEdits);
107
+ const offset = limit + 1;
108
+ const frontier = new Int32Array(2 * limit + 3);
109
+ const trace = [];
110
+ let edits = -1;
111
+ search: for (let d = 0; d <= limit; d++) {
112
+ trace.push(frontier.slice(offset - d, offset + d + 1));
113
+ for (let k = -d; k <= d; k += 2) {
114
+ let x = k === -d || k !== d && frontier[offset + k - 1] < frontier[offset + k + 1] ? frontier[offset + k + 1] : frontier[offset + k - 1] + 1;
115
+ let y = x - k;
116
+ while (x < n && y < m && before[x] === after[y]) {
117
+ x++;
118
+ y++;
119
+ }
120
+ frontier[offset + k] = x;
121
+ if (x >= n && y >= m) {
122
+ edits = d;
123
+ break search;
124
+ }
125
+ }
126
+ }
127
+ if (edits < 0) return null;
128
+ const reversed = [];
129
+ const push = (type, text) => {
130
+ const last = reversed.at(-1);
131
+ if (last?.type === type) last.text = text + last.text;
132
+ else reversed.push({
133
+ type,
134
+ text
135
+ });
136
+ };
137
+ let x = n;
138
+ let y = m;
139
+ for (let d = edits; d > 0; d--) {
140
+ const previous = trace[d];
141
+ const at = (diagonal) => previous[diagonal + d];
142
+ const k = x - y;
143
+ const down = k === -d || k !== d && at(k - 1) < at(k + 1);
144
+ const previousK = down ? k + 1 : k - 1;
145
+ const previousX = at(previousK);
146
+ const previousY = previousX - previousK;
147
+ while (x > previousX && y > previousY) {
148
+ x--;
149
+ y--;
150
+ push("equal", before[x]);
151
+ }
152
+ if (down) {
153
+ y--;
154
+ push("ins", after[y]);
155
+ } else {
156
+ x--;
157
+ push("del", before[x]);
158
+ }
159
+ }
160
+ while (x > 0 && y > 0) {
161
+ x--;
162
+ y--;
163
+ push("equal", before[x]);
164
+ }
165
+ return reversed.toReversed();
166
+ };
167
+ /**
168
+ * The changes from `source` to `replacement`, in source order and disjoint.
169
+ *
170
+ * Words (with their leading whitespace, and edge punctuation on its own; see
171
+ * `tokenizeWords`) are aligned by the shortest edit script, and each changed
172
+ * stretch is then trimmed to the characters that actually differ, so
173
+ * appending a letter to a word that spans two runs leaves both runs alone. A
174
+ * rewrite past {@link MAX_TOKEN_EDITS}, or a diff that does not reconstruct
175
+ * both strings exactly (it never should), is one change over the whole span,
176
+ * trimmed the same way.
177
+ */
178
+ const planTextChanges = (source, replacement) => {
179
+ if (source === replacement) return [];
180
+ const diffed = shortestTokenDiff(tokenizeWords(source), tokenizeWords(replacement));
181
+ const segments = diffed !== null && reconstructs(diffed, source, replacement) ? diffed : [{
182
+ type: "del",
183
+ text: source
184
+ }, {
185
+ type: "ins",
186
+ text: replacement
187
+ }];
188
+ return changesFromSegments(segments).flatMap((change) => trimChange(source, change) ?? []);
189
+ };
190
+ /**
191
+ * The changes a diff's segments describe, in source order and disjoint: each
192
+ * run of `del` and `ins` segments between two `equal` ones is one change,
193
+ * exactly as wide as the segments say. A redline's segments come cut to the
194
+ * granularity its reader asked for, so they are not trimmed further.
195
+ */
196
+ const changesFromSegments = (segments) => {
197
+ const changes = [];
198
+ let cursor = 0;
199
+ let pending = null;
200
+ for (const segment of segments) {
201
+ if (segment.type === "equal") {
202
+ if (pending !== null) {
203
+ changes.push(pending);
204
+ pending = null;
205
+ }
206
+ cursor += segment.text.length;
207
+ continue;
208
+ }
209
+ pending ??= {
210
+ start: cursor,
211
+ end: cursor,
212
+ text: ""
213
+ };
214
+ if (segment.type === "del") {
215
+ cursor += segment.text.length;
216
+ pending.end = cursor;
217
+ } else pending.text += segment.text;
218
+ }
219
+ if (pending !== null) changes.push(pending);
220
+ return changes;
221
+ };
222
+ const cutsInto = ({ offset, length }, position) => position > offset && position < offset + length;
223
+ /**
224
+ * `changes` widened so none cuts into an atomic span, then merged where the
225
+ * widening made two overlap. A widened change re-emits the source text it
226
+ * absorbed, so applying the result still yields exactly the replacement.
227
+ *
228
+ * `spans` are in the same coordinates as the changes (the source span's).
229
+ */
230
+ const widenChangesToAtomicSpans = (source, changes, spans) => {
231
+ if (spans.length === 0 || changes.length === 0) return [...changes];
232
+ const windows = changes.map(({ start, end }) => {
233
+ let from = start;
234
+ let to = end;
235
+ for (const span of spans) {
236
+ if (cutsInto(span, from)) from = span.offset;
237
+ if (cutsInto(span, to)) to = span.offset + span.length;
238
+ }
239
+ return {
240
+ from,
241
+ to
242
+ };
243
+ });
244
+ const merged = [];
245
+ let index = 0;
246
+ while (index < changes.length) {
247
+ const { from } = windows[index];
248
+ let { to } = windows[index];
249
+ const group = [changes[index]];
250
+ index++;
251
+ while (index < changes.length && windows[index].from < to) {
252
+ to = Math.max(to, windows[index].to);
253
+ group.push(changes[index]);
254
+ index++;
255
+ }
256
+ let text = "";
257
+ let cursor = from;
258
+ for (const change of group) {
259
+ text += source.slice(cursor, change.start) + change.text;
260
+ cursor = change.end;
261
+ }
262
+ text += source.slice(cursor, to);
263
+ merged.push({
264
+ start: from,
265
+ end: to,
266
+ text
267
+ });
268
+ }
269
+ return merged;
270
+ };
271
+ /** `source` with `changes` applied; the invariant the planner owes. */
272
+ const applyTextChanges = (source, changes) => {
273
+ let result = "";
274
+ let cursor = 0;
275
+ for (const change of changes) {
276
+ result += source.slice(cursor, change.start) + change.text;
277
+ cursor = change.end;
278
+ }
279
+ return result + source.slice(cursor);
280
+ };
281
+ //#endregion
282
+ export { MAX_TOKEN_EDITS, applyTextChanges, changesFromSegments, commonPrefixLength, commonSuffixLength, planTextChanges, shortestTokenDiff, widenChangesToAtomicSpans };
@@ -230,6 +230,15 @@ function runLayoutPipelineMeasured(deps, state, options) {
230
230
  preparedFooter: refs.footerEven ? footerContentByRId?.get(refs.footerEven) : void 0
231
231
  });
232
232
  });
233
+ const sectionFirstPageMargins = sectionHeaderFooterRefs?.map((refs, index) => {
234
+ if (refs.titlePg !== true) return;
235
+ const properties = sectionPropertiesForMargins[index];
236
+ return bodyMarginsClearHeaderFooter({
237
+ authoredMargins: properties ? getMargins(properties) : margins,
238
+ preparedHeader: refs.headerFirst ? headerContentByRId?.get(refs.headerFirst) : void 0,
239
+ preparedFooter: refs.footerFirst ? footerContentByRId?.get(refs.footerFirst) : void 0
240
+ });
241
+ });
233
242
  newBlocks = bodyBlocksClearSectionHeaderFooter(newBlocks, {
234
243
  authoredMargins: margins,
235
244
  sectionHeaderFooterRefs,
@@ -358,7 +367,8 @@ function runLayoutPipelineMeasured(deps, state, options) {
358
367
  };
359
368
  if (document?.package.document.sections !== void 0) nextLayoutOpts.sectionVerticalAlignments = document.package.document.sections.map(({ properties }) => properties.verticalAlign);
360
369
  else if (sectionProperties !== null && sectionProperties !== void 0) nextLayoutOpts.sectionVerticalAlignments = [sectionProperties.verticalAlign];
361
- if (hasTitlePg) nextLayoutOpts.firstPageMargins = bodyMarginsClearHeaderFooter({
370
+ if (sectionFirstPageMargins !== void 0) nextLayoutOpts.sectionFirstPageMargins = sectionFirstPageMargins;
371
+ else if (hasTitlePg) nextLayoutOpts.firstPageMargins = bodyMarginsClearHeaderFooter({
362
372
  authoredMargins: margins,
363
373
  preparedHeader: firstPageHeaderForRender,
364
374
  preparedFooter: firstPageFooterForRender
@@ -1,6 +1,7 @@
1
1
  import { getListMarkerInlineWidth, getListMarkerVisualOffset, resolveListMarkerFont } from "../../layout-engine/measure/listMarkerWidth.js";
2
2
  import { DEFAULT_FONT_FAMILY, buildRunFontStyle, ptToPx } from "../../layout-engine/measure/measureHelpers.js";
3
3
  import { getFontMetrics, measureTextWidth } from "../../layout-engine/measure/measureProvider.js";
4
+ import { SMALL_CAPS_SCALE, smallCapsSegments } from "../../layout-engine/measure/smallCapsCasing.js";
4
5
  import { calculateTabWidth, pixelsToTwips } from "../../layout-engine/measure/tabCalculator.js";
5
6
  import { countCompressibleSpaces, toPaintedText } from "../../layout-engine/measure/textMeasurementPolicy.js";
6
7
  import { isFloatingImageRun } from "../../layout-engine/types.js";
@@ -315,6 +316,39 @@ const runGlyphDirection = (run, paragraphIsRtl) => run.bidiWrapper?.direction ??
315
316
  */
316
317
  const runUnicodeBidi = (run) => run.bidiWrapper === void 0 ? void 0 : UNICODE_BIDI_BY_WRAPPER_CONTROL[run.bidiWrapper.control];
317
318
  /**
319
+ * `glyphs` split into same-size stretches for a `w:smallCaps` run, or
320
+ * `undefined` when it has no lowercase letter (a heading typed in caps, a run
321
+ * of digits and punctuation): that run paints as the single glyph run it
322
+ * always did, no backend work spent on a split it does not need.
323
+ *
324
+ * Every backend draws one glyph size per `glyphRun` (a PDF's `Tf` sets the
325
+ * whole text-showing operation's size; a DOM span sets one `font-size`), so a
326
+ * mixed-size run becomes a short run of primitives instead of one primitive
327
+ * with glyphs a backend cannot all draw at the size it is told. `advancesPx`
328
+ * is sliced from the already-measured run — the widths line breaking decided
329
+ * on — never re-measured, so the split cannot disagree with the width the
330
+ * line was fitted at.
331
+ */
332
+ const smallCapsGlyphRunSegments = (glyphs, fontSizePx) => {
333
+ const segments = smallCapsSegments(glyphs.text);
334
+ if (!segments.some((segment) => segment.small)) return;
335
+ const smallFontSizePx = fontSizePx * SMALL_CAPS_SCALE;
336
+ const result = [];
337
+ let advanceIndex = 0;
338
+ for (const segment of segments) {
339
+ const codePointCount = [...segment.text].length;
340
+ const advancesPx = glyphs.advancesPx.slice(advanceIndex, advanceIndex + codePointCount);
341
+ advanceIndex += codePointCount;
342
+ result.push({
343
+ text: segment.text,
344
+ fontSizePx: segment.small ? smallFontSizePx : fontSizePx,
345
+ advancesPx,
346
+ widthPx: advancesPx.reduce((sum, advance) => sum + advance, 0)
347
+ });
348
+ }
349
+ return result;
350
+ };
351
+ /**
318
352
  * One `glyphRun` plus everything painted around it: the run's background rect
319
353
  * first, then the glyphs, then the decorations whose geometry CSS would have
320
354
  * derived from the font.
@@ -380,20 +414,43 @@ const emitGlyphRun = ({ sink, context, run, glyphs, style, paintXPx, baselineYPx
380
414
  pattern: "solid"
381
415
  } : void 0;
382
416
  const unicodeBidi = runUnicodeBidi(run);
383
- sink.glyphs.push({
384
- kind: "glyphRun",
417
+ const direction = runGlyphDirection(run, isRtl);
418
+ const commonFields = {
385
419
  font,
386
- fontSizePx,
387
420
  color,
388
- xPx: paintXPx,
389
421
  baselineYPx: runBaselineYPx,
390
- ...glyphRunText(glyphs),
391
- direction: runGlyphDirection(run, isRtl),
422
+ direction,
392
423
  ...unicodeBidi === void 0 ? {} : { unicodeBidi },
393
424
  ...stroke === void 0 ? {} : { stroke },
394
425
  ...pmRange === void 0 ? {} : { pmRange },
395
426
  ...collapsedEdge === void 0 ? {} : { collapsedEdge }
427
+ };
428
+ const smallCapsRuns = style.fontVariant === "small-caps" && direction === "ltr" ? smallCapsGlyphRunSegments(glyphs, fontSizePx) : void 0;
429
+ if (smallCapsRuns === void 0) sink.glyphs.push({
430
+ kind: "glyphRun",
431
+ fontSizePx,
432
+ xPx: paintXPx,
433
+ ...glyphRunText(glyphs),
434
+ ...commonFields
396
435
  });
436
+ else {
437
+ const adjustments = glyphs.adjustments === void 0 ? {} : { adjustments: glyphs.adjustments };
438
+ let segmentXPx = paintXPx;
439
+ for (const segment of smallCapsRuns) {
440
+ sink.glyphs.push({
441
+ kind: "glyphRun",
442
+ fontSizePx: segment.fontSizePx,
443
+ xPx: segmentXPx,
444
+ text: segment.text,
445
+ advancesPx: segment.advancesPx,
446
+ kerning: glyphs.kerning,
447
+ smallCaps: true,
448
+ ...adjustments,
449
+ ...commonFields
450
+ });
451
+ segmentXPx += segment.widthPx;
452
+ }
453
+ }
397
454
  emitDecorations({
398
455
  sink,
399
456
  run,
@@ -506,7 +563,6 @@ const emitDecorations = ({ sink, run, color, context, xPx, glyphs, baselineYPx,
506
563
  */
507
564
  const reportRunEffects = (run, context) => {
508
565
  const { unsupported, pageIndex } = context;
509
- if (run.smallCaps) unsupported.report(UNSUPPORTED_CONSTRUCT.smallCaps, pageIndex, "w:smallCaps is carried on the run and painted by the DOM backend; the PDF backend paints full-size glyphs for it");
510
566
  if (getHorizontalScaleFactor(run.horizontalScale) !== 1) unsupported.report(UNSUPPORTED_CONSTRUCT.horizontalScale, pageIndex, `w:w ${String(run.horizontalScale)}% is carried on the run and painted by the DOM backend; the PDF backend paints unnarrowed glyphs at the measured advances`);
511
567
  if (run.textEffect) unsupported.report(UNSUPPORTED_CONSTRUCT.textEffect, pageIndex, `w:effect ${run.textEffect}`);
512
568
  if (run.emphasisMark) unsupported.report(UNSUPPORTED_CONSTRUCT.emphasisMark, pageIndex, `w:em ${run.emphasisMark}`);
@@ -64,7 +64,7 @@ const paintUnrotatedTextBoxFragment = ({ composer, fragment, block, measure, con
64
64
  fill
65
65
  });
66
66
  else context.unsupported.report(UNSUPPORTED_CONSTRUCT.unresolvedColor, context.pageIndex, `text box fill ${block.fillColor}`);
67
- }
67
+ } else if (block.fillGradient) context.unsupported.report(UNSUPPORTED_CONSTRUCT.textBoxGradientFill, context.pageIndex, "text box gradient fill");
68
68
  let outlineWidthPx = 0;
69
69
  const outlineDash = presetDashForOutlineAttr(block.outlineStyle) ?? "solid";
70
70
  if (block.outlineWidth !== void 0 && block.outlineWidth > 0 && outlineDash !== "none") {
@@ -7,8 +7,6 @@ import { DisplayUnsupported } from "../types.js";
7
7
  declare const UNSUPPORTED_CONSTRUCT: {
8
8
  /** `w:effect` animations (blinkBackground, shimmer, …). */
9
9
  readonly textEffect: "textEffect";
10
- /** `w:smallCaps`: advances are correct, the small-cap glyph choice is not expressible. */
11
- readonly smallCaps: "smallCaps";
12
10
  /** `w:w` horizontal scale: folded into advances, but the glyphs are not narrowed. */
13
11
  readonly horizontalScale: "horizontalScale";
14
12
  /** `w:em` emphasis marks. */
@@ -39,6 +37,8 @@ declare const UNSUPPORTED_CONSTRUCT: {
39
37
  readonly headerFooterContent: "headerFooterContent";
40
38
  /** `a:bodyPr` middle/bottom anchoring inside a text box. */
41
39
  readonly textBoxVerticalAlign: "textBoxVerticalAlign";
40
+ /** A text box's `a:gradFill`: the primitives carry no gradient. */
41
+ readonly textBoxGradientFill: "textBoxGradientFill";
42
42
  /** A fragment kind with no builder. */
43
43
  readonly fragmentKind: "fragmentKind";
44
44
  /** A fragment whose block or measure is missing from the block lookup. */
@@ -297,7 +297,6 @@ const paintGlyphRun = (run, context) => {
297
297
  if (run.unicodeBidi !== void 0) span.style.unicodeBidi = run.unicodeBidi;
298
298
  if (run.stroke !== void 0) span.style.webkitTextStroke = `${px(run.stroke.thicknessPx)} ${cssColor(run.stroke.color)}`;
299
299
  span.style.fontKerning = run.kerning ? "normal" : "none";
300
- if (run.smallCaps) span.style.fontVariant = "small-caps";
301
300
  applyAdjustments(span, run.adjustments);
302
301
  span.dataset["advanceSum"] = String(advanceSum);
303
302
  applyModelRange(span, run.pmRange);
@@ -1,5 +1,5 @@
1
1
  import { parseBookmarkEnd, parseBookmarkStart } from "./bookmarkParser.js";
2
- import { convertBulletToUnicode } from "./bulletMarkers.js";
2
+ import { bulletMarkerFontName, convertBulletToUnicode } from "./bulletMarkers.js";
3
3
  import { CAPTURE, dispatchChildrenWithContext, ownedElsewhere, withPreservedChildren } from "./containerChildren.js";
4
4
  import { isNumberingReference } from "./numberingReference.js";
5
5
  import { formatOoxmlCounter } from "./ooxmlCounterFormatter.js";
@@ -62,7 +62,7 @@ const computeListMarker = (paragraph, { numbering, listCounters, abstractCounter
62
62
  previousList.numId = numId;
63
63
  const pattern = listRendering.marker;
64
64
  if (listRendering.isBullet) {
65
- listRendering.marker = convertBulletToUnicode(pattern || "");
65
+ listRendering.marker = convertBulletToUnicode(pattern || "", bulletMarkerFontName(listRendering.markerFormatting));
66
66
  previousList.abstractNumId = null;
67
67
  previousList.fromStyle = false;
68
68
  previousList.numId = null;
@@ -1,4 +1,20 @@
1
1
  //#region src/docx/bulletMarkers.d.ts
2
- declare const convertBulletToUnicode: (bulletChar: string) => string;
2
+ type MarkerFontFamily = {
3
+ ascii?: string | undefined;
4
+ hAnsi?: string | undefined;
5
+ };
6
+ /** The face a bullet level's `w:rFonts` names for its (Latin-range) marker character. */
7
+ declare const bulletMarkerFontName: (formatting: {
8
+ fontFamily?: MarkerFontFamily | undefined;
9
+ } | null | undefined) => string | undefined;
10
+ /**
11
+ * Map a bullet level's `w:lvlText` to the character painted for it.
12
+ *
13
+ * `fontName` is the level's own `w:rFonts` face. When it is an ordinary text
14
+ * font, a Latin-range character is the letter itself (the common `o` bullet
15
+ * in a monospace face); without a named face the character is read as a
16
+ * symbol-font code.
17
+ */
18
+ declare const convertBulletToUnicode: (bulletChar: string, fontName?: string) => string;
3
19
  //#endregion
4
- export { convertBulletToUnicode };
20
+ export { bulletMarkerFontName, convertBulletToUnicode };
@@ -28,10 +28,35 @@ const SYMBOL_BULLET_MAP = {
28
28
  62: ">",
29
29
  45: "-"
30
30
  };
31
- const convertBulletToUnicode = (bulletChar) => {
31
+ /**
32
+ * Fonts whose single-byte codes name pictographs rather than Latin letters.
33
+ * A `w:lvlText` character below U+0100 only means a symbol glyph when the
34
+ * numbering level's `w:rFonts` selects one of these faces.
35
+ */
36
+ const SYMBOL_ENCODED_FONTS = /* @__PURE__ */ new Set([
37
+ "symbol",
38
+ "wingdings",
39
+ "wingdings 2",
40
+ "wingdings 3",
41
+ "webdings"
42
+ ]);
43
+ /** The face a bullet level's `w:rFonts` names for its (Latin-range) marker character. */
44
+ const bulletMarkerFontName = (formatting) => formatting?.fontFamily?.ascii ?? formatting?.fontFamily?.hAnsi;
45
+ /** A printable Latin-1 character in a named, non-symbol face paints as itself. */
46
+ const isTextFontCharacter = (charCode, fontName) => fontName !== void 0 && (charCode >= 32 && charCode < 127 || charCode >= 160 && charCode < 256) && !SYMBOL_ENCODED_FONTS.has(fontName.trim().toLowerCase());
47
+ /**
48
+ * Map a bullet level's `w:lvlText` to the character painted for it.
49
+ *
50
+ * `fontName` is the level's own `w:rFonts` face. When it is an ordinary text
51
+ * font, a Latin-range character is the letter itself (the common `o` bullet
52
+ * in a monospace face); without a named face the character is read as a
53
+ * symbol-font code.
54
+ */
55
+ const convertBulletToUnicode = (bulletChar, fontName) => {
32
56
  if (!bulletChar || bulletChar.trim() === "") return "•";
33
57
  const charCode = bulletChar.codePointAt(0);
34
58
  if (charCode === void 0) return "•";
59
+ if (isTextFontCharacter(charCode, fontName)) return bulletChar;
35
60
  const mapped = SYMBOL_BULLET_MAP[charCode];
36
61
  if (mapped !== void 0) return mapped;
37
62
  if (charCode >= 57344 && charCode <= 63743) return "•";
@@ -39,4 +64,4 @@ const convertBulletToUnicode = (bulletChar) => {
39
64
  return bulletChar;
40
65
  };
41
66
  //#endregion
42
- export { convertBulletToUnicode };
67
+ export { bulletMarkerFontName, convertBulletToUnicode };
@@ -0,0 +1,60 @@
1
+ import { XmlElement } from "./xmlParser.js";
2
+ //#region src/docx/drawingGroupChildren.d.ts
3
+ type GroupTextBoxFrame = {
4
+ /** Element indices from the `wpg:wgp` down to the `wps:wsp`. */
5
+ path: number[];
6
+ wsp: XmlElement;
7
+ /** EMUs from the group's top-left corner. */
8
+ x: number;
9
+ y: number;
10
+ width: number;
11
+ height: number;
12
+ /** Degrees clockwise. */
13
+ rotation: number;
14
+ flipH: boolean;
15
+ flipV: boolean;
16
+ };
17
+ type Transform2D = {
18
+ x: number;
19
+ y: number;
20
+ width: number;
21
+ height: number;
22
+ childX: number;
23
+ childY: number;
24
+ childWidth: number;
25
+ childHeight: number;
26
+ rotation: number;
27
+ flipH: boolean;
28
+ flipV: boolean;
29
+ };
30
+ /** A `CT_GroupTransform2D` or `CT_Transform2D`; absent children read as zero. */
31
+ declare const readTransform: (xfrm: XmlElement | null) => Transform2D;
32
+ /**
33
+ * Every `wps:wsp` with a text box in the group, nested groups included, with
34
+ * its frame in EMUs from the group's top-left corner.
35
+ *
36
+ * A nested group that is rotated or flipped turns its children's frames into
37
+ * something other than a rectangle in the drawing's axes, so its text boxes
38
+ * are left out and stay with the preview.
39
+ */
40
+ declare const collectGroupTextBoxes: (group: XmlElement, width: number, height: number) => GroupTextBoxFrame[];
41
+ /** Identity of a group drawing's authored XML. */
42
+ declare const groupXmlFingerprint: (rawXml: string) => string;
43
+ /** Identity of a text box's content, compared on save to tell an edit. */
44
+ declare const groupTextContentFingerprint: (content: unknown) => string;
45
+ type GroupTextBoxEdit = {
46
+ path: readonly number[];
47
+ /** Serialized `w:txbxContent` children. */
48
+ contentXml: string;
49
+ };
50
+ /**
51
+ * The captured group XML with each edited text box's content replaced, or
52
+ * undefined when an edit names no text box in it.
53
+ *
54
+ * The replacement is written as a placeholder element and substituted after
55
+ * the tree is serialized, so the new content is never reparsed and the rest
56
+ * of the capture is written exactly as it was read.
57
+ */
58
+ declare const replaceGroupTextBoxContent: (rawXml: string, edits: readonly GroupTextBoxEdit[]) => string | undefined;
59
+ //#endregion
60
+ export { GroupTextBoxEdit, GroupTextBoxFrame, collectGroupTextBoxes, groupTextContentFingerprint, groupXmlFingerprint, readTransform, replaceGroupTextBoxContent };