@stll/folio-core 0.42.0 → 0.43.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 (46) hide show
  1. package/dist/compat/eigenpal.d.ts +2 -2
  2. package/dist/controller/layoutPipeline.d.ts +2 -1
  3. package/dist/controller/layoutPipeline.js +5 -2
  4. package/dist/controller/layoutSession.d.ts +2 -1
  5. package/dist/controller/layoutSession.js +1 -0
  6. package/dist/docx/blockContentParser.js +2 -1
  7. package/dist/docx/commentIdNormalization.d.ts +8 -0
  8. package/dist/docx/commentIdNormalization.js +16 -0
  9. package/dist/docx/defaultParagraphStyle.d.ts +22 -0
  10. package/dist/docx/defaultParagraphStyle.js +32 -0
  11. package/dist/docx/footnoteParser.js +2 -0
  12. package/dist/docx/headerFooterRefParser.d.ts +12 -1
  13. package/dist/docx/headerFooterRefParser.js +9 -2
  14. package/dist/docx/numberingParser.js +2 -1
  15. package/dist/docx/numberingReference.d.ts +21 -0
  16. package/dist/docx/numberingReference.js +21 -0
  17. package/dist/docx/numberingReferenceNormalization.d.ts +11 -2
  18. package/dist/docx/numberingReferenceNormalization.js +32 -9
  19. package/dist/docx/paragraphParser.js +3 -2
  20. package/dist/docx/parser.js +10 -2
  21. package/dist/docx/rezip.js +22 -5
  22. package/dist/docx/server/createBilingualDocument.js +5 -4
  23. package/dist/docx/styleParser.js +2 -2
  24. package/dist/index.d.ts +2 -2
  25. package/dist/internal/pageBreakRunSourceDescendantIndex.d.ts +2 -0
  26. package/dist/internal/pageBreakRunSourceDescendantIndex.js +7 -5
  27. package/dist/layout-bridge/convert/templatePreviewFlow.d.ts +19 -11
  28. package/dist/layout-bridge/convert/templatePreviewFlow.js +103 -38
  29. package/dist/layout-bridge/convert/toFlowBlocks.js +12 -3
  30. package/dist/prosemirror/commands/pageBreak.js +12 -1
  31. package/dist/prosemirror/conversion/fromProseDoc.js +2 -1
  32. package/dist/prosemirror/conversion/toProseDoc.js +27 -12
  33. package/dist/prosemirror/extensions/features/ListExtension.js +42 -4
  34. package/dist/prosemirror/listMarker.js +2 -1
  35. package/dist/prosemirror/numberedRefFields.js +2 -1
  36. package/dist/prosemirror/pageBreakRunProjection.d.ts +11 -3
  37. package/dist/prosemirror/pageBreakRunProjection.js +16 -8
  38. package/dist/prosemirror/plugins/templatePreviewValues.d.ts +42 -1
  39. package/dist/prosemirror/plugins/templatePreviewValues.js +217 -14
  40. package/dist/prosemirror/styles/resolvedStyleAttrs.js +2 -1
  41. package/dist/prosemirror/styles/styleResolver.js +3 -6
  42. package/dist/prosemirror/utils/visualLineNavigation.d.ts +22 -2
  43. package/dist/prosemirror/utils/visualLineNavigation.js +80 -63
  44. package/dist/style-engine/styleEngine.d.ts +1 -1
  45. package/dist/style-sets/extract.js +57 -11
  46. package/package.json +2 -1
@@ -1,7 +1,8 @@
1
+ import { findCollapsedLineEdgeCaretTarget } from "../../layout-bridge/dom/clickToPositionDom.js";
1
2
  import { findBodyEmptyRuns, findBodyPmSpans } from "../../layout-bridge/dom/findBodyPmSpans.js";
2
3
  import { createTextStreamRange, descendantTextNodes, totalTextLength } from "../../layout-bridge/dom/textStreamDom.js";
3
4
  import { findVerticalScrollParent } from "../../utils/findVerticalScrollParent.js";
4
- import { TextSelection } from "prosemirror-state";
5
+ import { Selection, TextSelection } from "prosemirror-state";
5
6
  //#region src/prosemirror/utils/visualLineNavigation.ts
6
7
  /**
7
8
  * Visual line navigation helpers — implements Word/Google-Docs-style
@@ -17,12 +18,10 @@ import { TextSelection } from "prosemirror-state";
17
18
  * @internal
18
19
  */
19
20
  const CONTENT_LINE_SELECTOR = ".layout-page-content .layout-line";
21
+ const PM_SPAN_SELECTOR = "span[data-pm-start][data-pm-end]";
20
22
  /** @internal */
21
23
  function createVisualLineState() {
22
- return {
23
- stickyX: null,
24
- lastVisualLineIndex: -1
25
- };
24
+ return { step: null };
26
25
  }
27
26
  function scrollIntoViewIfNeeded(el) {
28
27
  const container = findVerticalScrollParent(el);
@@ -35,7 +34,10 @@ function scrollIntoViewIfNeeded(el) {
35
34
  }
36
35
  /** @internal */
37
36
  function getCaretClientX(container, pmPos) {
38
- for (const spanEl of findBodyPmSpans(container)) {
37
+ const spans = findBodyPmSpans(container);
38
+ const collapsedTarget = findCollapsedLineEdgeCaretTarget(spans, pmPos);
39
+ if (collapsedTarget) return collapsedTarget.geometry.left;
40
+ for (const spanEl of spans) {
39
41
  const pmStart = Number(spanEl.dataset["pmStart"]);
40
42
  const pmEnd = Number(spanEl.dataset["pmEnd"]);
41
43
  if (spanEl.classList.contains("layout-run-tab")) {
@@ -58,21 +60,24 @@ function getCaretClientX(container, pmPos) {
58
60
  }
59
61
  return null;
60
62
  }
63
+ /**
64
+ * Whether one painted line carries `pmPos`. Span endpoints are inclusive, so a
65
+ * soft-wrap boundary answers true for both the line it ends and the line it
66
+ * starts.
67
+ */
68
+ const lineCarriesPosition = (lineEl, pmPos) => {
69
+ for (const span of Array.from(lineEl.querySelectorAll(PM_SPAN_SELECTOR))) {
70
+ const start = Number(span.dataset["pmStart"]);
71
+ const end = Number(span.dataset["pmEnd"]);
72
+ if (pmPos >= start && pmPos <= end) return true;
73
+ }
74
+ return false;
75
+ };
61
76
  /** @internal */
62
77
  function findLineElementAtPosition(container, pmPos) {
63
78
  const allLines = container.querySelectorAll(CONTENT_LINE_SELECTOR);
64
- for (const line of Array.from(allLines)) {
65
- const lineEl = line;
66
- const spans = lineEl.querySelectorAll("span[data-pm-start][data-pm-end]");
67
- for (const span of Array.from(spans)) {
68
- const s = span;
69
- const start = Number(s.dataset["pmStart"]);
70
- const end = Number(s.dataset["pmEnd"]);
71
- if (pmPos >= start && pmPos <= end) return lineEl;
72
- }
73
- }
74
- for (const line of Array.from(allLines)) {
75
- const lineEl = line;
79
+ for (const lineEl of Array.from(allLines)) if (lineCarriesPosition(lineEl, pmPos)) return lineEl;
80
+ for (const lineEl of Array.from(allLines)) {
76
81
  const paragraph = lineEl.closest(".layout-paragraph");
77
82
  if (!paragraph) continue;
78
83
  const pStart = Number(paragraph.dataset["pmStart"]);
@@ -145,6 +150,36 @@ function findPositionOnLineAtClientX(lineEl, clientX) {
145
150
  return clientX < closestSpan.getBoundingClientRect().left ? Number(closestSpan.dataset["pmStart"]) : Number(closestSpan.dataset["pmEnd"]);
146
151
  }
147
152
  /**
153
+ * The step the next vertical move continues from: the remembered one while the
154
+ * caret is still where that move left it, the caret's own painted geometry
155
+ * otherwise.
156
+ */
157
+ const resolveVisualLineStep = ({ state, container, allLines, pmPos }) => {
158
+ const remembered = state.step;
159
+ if (remembered?.pmPos === pmPos) {
160
+ const rememberedLine = allLines[remembered.lineIndex];
161
+ if (rememberedLine && lineCarriesPosition(rememberedLine, pmPos)) return remembered;
162
+ }
163
+ const currentLine = findLineElementAtPosition(container, pmPos);
164
+ if (!currentLine) return null;
165
+ const lineIndex = allLines.indexOf(currentLine);
166
+ if (lineIndex === -1) return null;
167
+ const stickyX = getCaretClientX(container, pmPos);
168
+ return stickyX === null ? null : {
169
+ stickyX,
170
+ lineIndex,
171
+ pmPos
172
+ };
173
+ };
174
+ const createVerticalSelection = ({ doc, anchor, head, extend }) => {
175
+ try {
176
+ return extend ? TextSelection.create(doc, anchor, head) : TextSelection.create(doc, head);
177
+ } catch {
178
+ const $head = doc.resolve(head);
179
+ return extend ? TextSelection.between(doc.resolve(anchor), $head) : Selection.near($head);
180
+ }
181
+ };
182
+ /**
148
183
  * Handle PM ArrowUp / ArrowDown with visual-line awareness + sticky
149
184
  * X. Returns true if the event was handled and PM should not run
150
185
  * its default behaviour. Mutates `state` so consecutive presses
@@ -153,59 +188,41 @@ function findPositionOnLineAtClientX(lineEl, clientX) {
153
188
  * @internal
154
189
  */
155
190
  function handleVisualLineKeyDown(state, view, event, container) {
156
- if (event.key !== "ArrowUp" && event.key !== "ArrowDown") {
157
- if ([
158
- "ArrowLeft",
159
- "ArrowRight",
160
- "Home",
161
- "End"
162
- ].includes(event.key) || event.key.length === 1 && !event.ctrlKey && !event.metaKey) {
163
- state.stickyX = null;
164
- state.lastVisualLineIndex = -1;
165
- }
166
- return false;
167
- }
168
- if (event.ctrlKey || event.metaKey) {
169
- state.stickyX = null;
170
- state.lastVisualLineIndex = -1;
171
- return false;
172
- }
191
+ if (event.key !== "ArrowUp" && event.key !== "ArrowDown") return false;
192
+ if (event.ctrlKey || event.metaKey) return false;
173
193
  if (!container) return false;
174
194
  const allLines = Array.from(container.querySelectorAll(CONTENT_LINE_SELECTOR));
175
195
  if (allLines.length === 0) return false;
176
- const { from, anchor } = view.state.selection;
177
- if (state.stickyX === null) {
178
- const clientX = getCaretClientX(container, from);
179
- if (clientX === null) return false;
180
- state.stickyX = clientX;
181
- }
182
- let currentIndex;
183
- if (state.lastVisualLineIndex >= 0 && state.lastVisualLineIndex < allLines.length) currentIndex = state.lastVisualLineIndex;
184
- else {
185
- const currentLine = findLineElementAtPosition(container, from);
186
- if (!currentLine) return false;
187
- currentIndex = allLines.indexOf(currentLine);
188
- if (currentIndex === -1) return false;
189
- }
190
- const targetIndex = event.key === "ArrowUp" ? currentIndex - 1 : currentIndex + 1;
191
- if (targetIndex < 0 || targetIndex >= allLines.length) {
192
- state.lastVisualLineIndex = -1;
196
+ const { head, anchor } = view.state.selection;
197
+ const step = resolveVisualLineStep({
198
+ state,
199
+ container,
200
+ allLines,
201
+ pmPos: head
202
+ });
203
+ if (!step) return false;
204
+ const targetIndex = event.key === "ArrowUp" ? step.lineIndex - 1 : step.lineIndex + 1;
205
+ const targetLine = allLines[targetIndex];
206
+ if (!targetLine) {
207
+ state.step = null;
193
208
  return false;
194
209
  }
195
- const targetLine = allLines[targetIndex];
196
- const newPos = findPositionOnLineAtClientX(targetLine, state.stickyX);
210
+ const newPos = findPositionOnLineAtClientX(targetLine, step.stickyX);
197
211
  if (newPos === null) return false;
198
- state.lastVisualLineIndex = targetIndex;
199
212
  const { state: pmState, dispatch } = view;
200
213
  const clampedPos = Math.max(0, Math.min(newPos, pmState.doc.content.size));
201
- try {
202
- const sel = event.shiftKey ? TextSelection.create(pmState.doc, anchor, clampedPos) : TextSelection.create(pmState.doc, clampedPos);
203
- dispatch(pmState.tr.setSelection(sel));
204
- } catch {
205
- const $newPos = pmState.doc.resolve(clampedPos);
206
- const sel = event.shiftKey ? TextSelection.between(pmState.doc.resolve(anchor), $newPos) : TextSelection.near($newPos);
207
- dispatch(pmState.tr.setSelection(sel));
208
- }
214
+ const selection = createVerticalSelection({
215
+ doc: pmState.doc,
216
+ anchor,
217
+ head: clampedPos,
218
+ extend: event.shiftKey
219
+ });
220
+ dispatch(pmState.tr.setSelection(selection));
221
+ state.step = {
222
+ stickyX: step.stickyX,
223
+ lineIndex: targetIndex,
224
+ pmPos: selection.head
225
+ };
209
226
  scrollIntoViewIfNeeded(targetLine);
210
227
  return true;
211
228
  }
@@ -34,7 +34,7 @@ type StyleEngine = {
34
34
  hasStyle: (styleId: string) => boolean;
35
35
  /** Return the document-wide defaults (`w:docDefaults`). */
36
36
  getDocDefaults: () => document_d_exports.DocDefaults | undefined;
37
- /** The style flagged `w:default="1"` for paragraphs, else "Normal". */
37
+ /** The paragraph default per ECMA-376 17.7.4.17, else the built-in Normal. */
38
38
  getDefaultParagraphStyle: () => document_d_exports.Style | undefined;
39
39
  /** The style flagged `w:default="1"` for character styles. */
40
40
  getDefaultCharacterStyle: () => document_d_exports.Style | undefined;
@@ -1,10 +1,14 @@
1
+ import { BUILT_IN_DEFAULT_PARAGRAPH_FORMATTING, BUILT_IN_DEFAULT_PARAGRAPH_STYLE_ID, BUILT_IN_DEFAULT_PARAGRAPH_STYLE_NAME, resolveDefaultParagraphStyle } from "../docx/defaultParagraphStyle.js";
2
+ import { getCachedNumberingMap } from "../docx/numberingParser.js";
3
+ import { isNumberingReference } from "../docx/numberingReference.js";
4
+ import { normalizeStyleNumberingReferences } from "../docx/numberingReferenceNormalization.js";
1
5
  import { parseDocx } from "../docx/parser.js";
2
6
  import "./types.js";
3
7
  import { panic } from "better-result";
4
8
  //#region src/style-sets/extract.ts
5
9
  const inspectDocumentStyles = (document) => {
6
10
  const styles = document.package.styles?.styles ?? [];
7
- const defaultParagraphStyleId = styles.find((style) => style.type === "paragraph" && style.default)?.styleId;
11
+ const defaultParagraphStyleId = resolveDefaultParagraphStyle(styles)?.styleId;
8
12
  return {
9
13
  ...defaultParagraphStyleId ? { defaultParagraphStyleId } : {},
10
14
  styles: styles.map(toCatalogEntry)
@@ -24,15 +28,21 @@ const inspectDocumentStylesFromDocx = async (input) => inspectDocumentStyles(awa
24
28
  * are retained. No source content or package relationship can enter the result.
25
29
  */
26
30
  const extractDocumentStyleSet = (document, options) => {
31
+ if (options.styleIds?.length === 0) return panic("Cannot extract an empty style set");
27
32
  const definitions = document.package.styles;
28
- if (!definitions || definitions.styles.length === 0) return panic("Cannot extract a style set from a document without styles");
29
- const stylesById = new Map(definitions.styles.map((style) => [style.styleId, style]));
33
+ const sourceStyles = definitions?.styles ?? [];
34
+ const stylesById = new Map(sourceStyles.map((style) => [style.styleId, style]));
30
35
  const selectedStyleIds = options.styleIds === void 0 ? new Set(stylesById.keys()) : collectStyleDependencyClosure(stylesById, options.styleIds);
31
- const styles = definitions.styles.filter((style) => selectedStyleIds.has(style.styleId));
32
- if (styles.length === 0) return panic("Cannot extract an empty style set");
33
- const defaultParagraphStyle = styles.find((style) => style.type === "paragraph" && style.default);
34
- const initialParagraphStyleId = options.initialParagraphStyleId ?? defaultParagraphStyle?.styleId ?? "Normal";
35
- if (!styles.find((style) => style.styleId === initialParagraphStyleId && style.type === "paragraph")) return panic(`Initial paragraph style "${initialParagraphStyleId}" is not present in the extracted set`);
36
+ const styles = structuredClone(sourceStyles.filter((style) => selectedStyleIds.has(style.styleId)));
37
+ normalizeStyleNumberingReferences({
38
+ styles,
39
+ numbering: document.package.numbering ? getCachedNumberingMap(document.package.numbering) : void 0
40
+ });
41
+ const initialParagraphStyleId = ensureInitialParagraphStyle({
42
+ styles,
43
+ requested: options.initialParagraphStyleId,
44
+ hasDocDefaults: definitions?.docDefaults !== void 0
45
+ });
36
46
  const numbering = extractReferencedNumbering(styles, document);
37
47
  const fontTable = sanitizeFontTable(document.package.fontTable);
38
48
  return structuredClone({
@@ -40,8 +50,8 @@ const extractDocumentStyleSet = (document, options) => {
40
50
  name: options.name,
41
51
  initialParagraphStyleId,
42
52
  styles: {
43
- ...definitions.docDefaults ? { docDefaults: definitions.docDefaults } : {},
44
- ...definitions.latentStyles ? { latentStyles: definitions.latentStyles } : {},
53
+ ...definitions?.docDefaults ? { docDefaults: definitions.docDefaults } : {},
54
+ ...definitions?.latentStyles ? { latentStyles: definitions.latentStyles } : {},
45
55
  styles
46
56
  },
47
57
  ...numbering ? { numbering } : {},
@@ -59,6 +69,42 @@ const extractDocumentStyleSetFromDocx = async (input, options) => {
59
69
  });
60
70
  return extractDocumentStyleSet(document, options);
61
71
  };
72
+ const ensureInitialParagraphStyle = ({ styles, requested, hasDocDefaults }) => {
73
+ if (requested !== void 0) {
74
+ if (!styles.find((style) => style.styleId === requested && style.type === "paragraph")) return panic(`Initial paragraph style "${requested}" is not present in the extracted set`);
75
+ return requested;
76
+ }
77
+ const resolved = resolveDefaultParagraphStyle(styles);
78
+ if (resolved) return resolved.styleId;
79
+ const minted = mintDefaultParagraphStyle({
80
+ takenStyleIds: new Set(styles.map((style) => style.styleId)),
81
+ hasDocDefaults
82
+ });
83
+ styles.push(minted);
84
+ return minted.styleId;
85
+ };
86
+ /**
87
+ * The default paragraph style a set needs when its source declared none.
88
+ *
89
+ * The id only has to be free, because the set is what defines it. The
90
+ * formatting has to be the built-in template's whenever the source had no
91
+ * `w:docDefaults`, because that is what the source itself rendered as: a
92
+ * consumer applies its built-in Normal only where no default paragraph style
93
+ * exists, and this minted style is one. Where the source did declare
94
+ * `w:docDefaults`, the set carries them and they remain authoritative, so the
95
+ * minted style states nothing.
96
+ */
97
+ const mintDefaultParagraphStyle = ({ takenStyleIds, hasDocDefaults }) => {
98
+ let styleId = BUILT_IN_DEFAULT_PARAGRAPH_STYLE_ID;
99
+ for (let suffix = 1; takenStyleIds.has(styleId); suffix += 1) styleId = `${BUILT_IN_DEFAULT_PARAGRAPH_STYLE_ID}${suffix}`;
100
+ return {
101
+ styleId,
102
+ type: "paragraph",
103
+ name: BUILT_IN_DEFAULT_PARAGRAPH_STYLE_NAME,
104
+ default: true,
105
+ ...hasDocDefaults ? {} : { pPr: { ...BUILT_IN_DEFAULT_PARAGRAPH_FORMATTING } }
106
+ };
107
+ };
62
108
  const collectStyleDependencyClosure = (stylesById, requestedStyleIds) => {
63
109
  const selected = /* @__PURE__ */ new Set();
64
110
  const pending = [...requestedStyleIds];
@@ -82,7 +128,7 @@ const extractReferencedNumbering = (styles, document) => {
82
128
  const referencedNumIds = /* @__PURE__ */ new Set();
83
129
  for (const style of styles) {
84
130
  const numId = style.pPr?.numPr?.numId;
85
- if (numId !== void 0) referencedNumIds.add(numId);
131
+ if (isNumberingReference(numId)) referencedNumIds.add(numId);
86
132
  }
87
133
  if (referencedNumIds.size === 0) return;
88
134
  const nums = source.nums.filter((numbering) => referencedNumIds.has(numbering.numId));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stll/folio-core",
3
- "version": "0.42.0",
3
+ "version": "0.43.0",
4
4
  "description": "Headless, framework-neutral core of folio: the OOXML (.docx) parser, document model, ProseMirror integration, and page-layout engine. No React.",
5
5
  "keywords": [
6
6
  "document-model",
@@ -134,6 +134,7 @@
134
134
  "prosemirror-dropcursor": "^1.8.2",
135
135
  "prosemirror-gapcursor": "^1.4.1",
136
136
  "prosemirror-history": "^1.5.0",
137
+ "prosemirror-inputrules": "^1.5.0",
137
138
  "prosemirror-keymap": "^1.2.3",
138
139
  "prosemirror-model": "^1.25.9",
139
140
  "prosemirror-state": "^1.4.4",