webtex-cn 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +2 -0
- package/bin/webtex-cn.js +195 -0
- package/dist/base.css +513 -0
- package/dist/honglou.css +35 -0
- package/dist/minimal.css +26 -0
- package/dist/siku-quanshu-colored.css +31 -0
- package/dist/siku-quanshu.css +33 -0
- package/dist/webtex-cn.css +653 -0
- package/dist/webtex-cn.esm.js +1788 -0
- package/dist/webtex-cn.js +1812 -0
- package/dist/webtex-cn.min.js +46 -0
- package/package.json +59 -0
- package/src/config/templates.js +49 -0
- package/src/index.js +129 -0
- package/src/layout/grid-layout.js +484 -0
- package/src/model/nodes.js +94 -0
- package/src/parser/commands.js +156 -0
- package/src/parser/index.js +27 -0
- package/src/parser/parser.js +472 -0
- package/src/parser/tokenizer.js +228 -0
- package/src/renderer/html-renderer.js +643 -0
- package/src/templates/base.css +513 -0
- package/src/templates/honglou.css +35 -0
- package/src/templates/minimal.css +26 -0
- package/src/templates/siku-quanshu-colored.css +31 -0
- package/src/templates/siku-quanshu.css +33 -0
|
@@ -0,0 +1,484 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Grid Layout Engine.
|
|
3
|
+
*
|
|
4
|
+
* Walks the AST and assigns every node to a page/column/row coordinate.
|
|
5
|
+
* Produces a LayoutResult consumed by the renderer.
|
|
6
|
+
*
|
|
7
|
+
* Pipeline: Parser → AST → **layout()** → LayoutResult → HTMLRenderer
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { NodeType } from '../model/nodes.js';
|
|
11
|
+
import { resolveTemplateId, getGridConfig } from '../config/templates.js';
|
|
12
|
+
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
// Helpers (shared with renderer)
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Get plain text content from a list of child nodes.
|
|
19
|
+
*/
|
|
20
|
+
export function getPlainText(children) {
|
|
21
|
+
let text = '';
|
|
22
|
+
for (const child of children) {
|
|
23
|
+
if (child.type === NodeType.TEXT) {
|
|
24
|
+
text += child.value;
|
|
25
|
+
} else if (child.children && child.children.length > 0) {
|
|
26
|
+
text += getPlainText(child.children);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return text;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Split jiazhu text into two balanced columns.
|
|
34
|
+
*/
|
|
35
|
+
export function splitJiazhu(text, align = 'outward') {
|
|
36
|
+
const chars = [...text];
|
|
37
|
+
if (chars.length === 0) return { col1: '', col2: '' };
|
|
38
|
+
if (chars.length === 1) return { col1: chars[0], col2: '' };
|
|
39
|
+
|
|
40
|
+
const mid = align === 'inward'
|
|
41
|
+
? Math.floor(chars.length / 2)
|
|
42
|
+
: Math.ceil(chars.length / 2);
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
col1: chars.slice(0, mid).join(''),
|
|
46
|
+
col2: chars.slice(mid).join(''),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Split long jiazhu text into multiple dual-column segments.
|
|
52
|
+
* firstMaxPerCol allows the first segment to use remaining column space.
|
|
53
|
+
*/
|
|
54
|
+
export function splitJiazhuMulti(text, maxCharsPerCol = 20, align = 'outward', firstMaxPerCol = 0) {
|
|
55
|
+
const first = firstMaxPerCol > 0 ? firstMaxPerCol : maxCharsPerCol;
|
|
56
|
+
const chars = [...text];
|
|
57
|
+
const firstChunkSize = first * 2;
|
|
58
|
+
if (chars.length <= firstChunkSize) {
|
|
59
|
+
return [splitJiazhu(text, align)];
|
|
60
|
+
}
|
|
61
|
+
const segments = [];
|
|
62
|
+
const firstChunk = chars.slice(0, firstChunkSize).join('');
|
|
63
|
+
segments.push(splitJiazhu(firstChunk, align));
|
|
64
|
+
const fullChunkSize = maxCharsPerCol * 2;
|
|
65
|
+
for (let i = firstChunkSize; i < chars.length; i += fullChunkSize) {
|
|
66
|
+
const chunk = chars.slice(i, i + fullChunkSize).join('');
|
|
67
|
+
segments.push(splitJiazhu(chunk, align));
|
|
68
|
+
}
|
|
69
|
+
return segments;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
// Layout markers — used to wrap compound nodes across page boundaries
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
export const LayoutMarker = {
|
|
77
|
+
PARAGRAPH_START: '_paragraphStart',
|
|
78
|
+
PARAGRAPH_END: '_paragraphEnd',
|
|
79
|
+
LIST_START: '_listStart',
|
|
80
|
+
LIST_END: '_listEnd',
|
|
81
|
+
LIST_ITEM_START: '_listItemStart',
|
|
82
|
+
LIST_ITEM_END: '_listItemEnd',
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
// GridLayoutEngine
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
function newPage() {
|
|
90
|
+
return { items: [], floats: [], halfBoundary: null };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export class GridLayoutEngine {
|
|
94
|
+
/**
|
|
95
|
+
* @param {number} nRows Chars per column
|
|
96
|
+
* @param {number} nCols Columns per half-page
|
|
97
|
+
*/
|
|
98
|
+
constructor(nRows, nCols) {
|
|
99
|
+
this.nRows = nRows;
|
|
100
|
+
this.nCols = nCols;
|
|
101
|
+
this.colsPerSpread = 2 * nCols;
|
|
102
|
+
|
|
103
|
+
// Virtual cursor
|
|
104
|
+
this.currentCol = 0;
|
|
105
|
+
this.currentRow = 0;
|
|
106
|
+
this.currentIndent = 0;
|
|
107
|
+
|
|
108
|
+
// Pages
|
|
109
|
+
this.pages = [newPage()];
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
get currentPage() {
|
|
113
|
+
return this.pages[this.pages.length - 1];
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
get effectiveRows() {
|
|
117
|
+
return this.nRows - this.currentIndent;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Check and mark the half-page boundary when crossing from right to left.
|
|
122
|
+
*/
|
|
123
|
+
checkHalfBoundary() {
|
|
124
|
+
if (this.currentPage.halfBoundary === null && this.currentCol >= this.nCols) {
|
|
125
|
+
this.currentPage.halfBoundary = this.currentPage.items.length;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Advance to the next column. Triggers page break if needed.
|
|
131
|
+
*/
|
|
132
|
+
advanceColumn() {
|
|
133
|
+
this.currentCol++;
|
|
134
|
+
this.currentRow = 0;
|
|
135
|
+
this.checkHalfBoundary();
|
|
136
|
+
if (this.currentCol >= this.colsPerSpread) {
|
|
137
|
+
this.newPageBreak();
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Create a new page and reset cursor.
|
|
143
|
+
*/
|
|
144
|
+
newPageBreak() {
|
|
145
|
+
if (this.currentPage.halfBoundary === null) {
|
|
146
|
+
this.currentPage.halfBoundary = this.currentPage.items.length;
|
|
147
|
+
}
|
|
148
|
+
this.pages.push(newPage());
|
|
149
|
+
this.currentCol = 0;
|
|
150
|
+
this.currentRow = 0;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Place a node at the current cursor position.
|
|
155
|
+
*/
|
|
156
|
+
placeItem(node, extra = {}) {
|
|
157
|
+
this.checkHalfBoundary();
|
|
158
|
+
this.currentPage.items.push({
|
|
159
|
+
node,
|
|
160
|
+
col: this.currentCol,
|
|
161
|
+
row: this.currentRow,
|
|
162
|
+
indent: this.currentIndent,
|
|
163
|
+
...extra,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Place a layout marker (paragraph start/end, list start/end, etc.).
|
|
169
|
+
*/
|
|
170
|
+
placeMarker(markerType, data = {}) {
|
|
171
|
+
this.checkHalfBoundary();
|
|
172
|
+
this.currentPage.items.push({
|
|
173
|
+
node: { type: markerType },
|
|
174
|
+
col: this.currentCol,
|
|
175
|
+
row: this.currentRow,
|
|
176
|
+
indent: this.currentIndent,
|
|
177
|
+
...data,
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Walk a list of AST child nodes.
|
|
183
|
+
*/
|
|
184
|
+
walkChildren(children) {
|
|
185
|
+
for (const child of children) {
|
|
186
|
+
this.walkNode(child);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Advance cursor by a given number of rows, wrapping columns as needed.
|
|
192
|
+
* Preserves the remainder correctly across column and page breaks.
|
|
193
|
+
*/
|
|
194
|
+
advanceRows(count) {
|
|
195
|
+
this.currentRow += count;
|
|
196
|
+
while (this.currentRow >= this.effectiveRows) {
|
|
197
|
+
this.currentRow -= this.effectiveRows;
|
|
198
|
+
this.currentCol++;
|
|
199
|
+
this.checkHalfBoundary();
|
|
200
|
+
if (this.currentCol >= this.colsPerSpread) {
|
|
201
|
+
const remainder = this.currentRow;
|
|
202
|
+
this.newPageBreak();
|
|
203
|
+
this.currentRow = remainder;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Walk a single AST node and place it on the grid.
|
|
210
|
+
*/
|
|
211
|
+
walkNode(node) {
|
|
212
|
+
if (!node) return;
|
|
213
|
+
|
|
214
|
+
switch (node.type) {
|
|
215
|
+
case 'body':
|
|
216
|
+
this.walkChildren(node.children);
|
|
217
|
+
break;
|
|
218
|
+
|
|
219
|
+
case NodeType.CONTENT_BLOCK:
|
|
220
|
+
this.walkContentBlock(node);
|
|
221
|
+
break;
|
|
222
|
+
|
|
223
|
+
case NodeType.PARAGRAPH:
|
|
224
|
+
this.walkParagraph(node);
|
|
225
|
+
break;
|
|
226
|
+
|
|
227
|
+
case NodeType.TEXT:
|
|
228
|
+
this.walkText(node);
|
|
229
|
+
break;
|
|
230
|
+
|
|
231
|
+
case NodeType.NEWLINE:
|
|
232
|
+
case NodeType.PARAGRAPH_BREAK:
|
|
233
|
+
case NodeType.COLUMN_BREAK:
|
|
234
|
+
this.placeItem(node);
|
|
235
|
+
this.advanceColumn();
|
|
236
|
+
break;
|
|
237
|
+
|
|
238
|
+
case NodeType.JIAZHU:
|
|
239
|
+
this.walkJiazhu(node);
|
|
240
|
+
break;
|
|
241
|
+
|
|
242
|
+
case NodeType.SPACE:
|
|
243
|
+
case NodeType.NUOTAI: {
|
|
244
|
+
const count = parseInt(node.value, 10) || 1;
|
|
245
|
+
this.placeItem(node);
|
|
246
|
+
this.advanceRows(count);
|
|
247
|
+
break;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
case NodeType.TAITOU: {
|
|
251
|
+
this.advanceColumn();
|
|
252
|
+
const level = parseInt(node.value, 10) || 0;
|
|
253
|
+
this.currentRow = level;
|
|
254
|
+
this.placeItem(node);
|
|
255
|
+
break;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
case NodeType.LIST:
|
|
259
|
+
this.walkList(node);
|
|
260
|
+
break;
|
|
261
|
+
|
|
262
|
+
case NodeType.LIST_ITEM:
|
|
263
|
+
this.walkListItem(node);
|
|
264
|
+
break;
|
|
265
|
+
|
|
266
|
+
// Floating elements — don't consume grid space
|
|
267
|
+
case NodeType.MEIPI:
|
|
268
|
+
case NodeType.PIZHU:
|
|
269
|
+
case NodeType.STAMP:
|
|
270
|
+
this.currentPage.floats.push(node);
|
|
271
|
+
break;
|
|
272
|
+
|
|
273
|
+
// Decorative wrappers — place as single item, count text for cursor
|
|
274
|
+
case NodeType.EMPHASIS:
|
|
275
|
+
case NodeType.PROPER_NAME:
|
|
276
|
+
case NodeType.BOOK_TITLE:
|
|
277
|
+
case NodeType.INVERTED:
|
|
278
|
+
case NodeType.OCTAGON:
|
|
279
|
+
case NodeType.CIRCLED:
|
|
280
|
+
case NodeType.INVERTED_OCTAGON:
|
|
281
|
+
case NodeType.FIX:
|
|
282
|
+
case NodeType.DECORATE:
|
|
283
|
+
this.placeItem(node);
|
|
284
|
+
this.advanceRowsByNodeText(node);
|
|
285
|
+
break;
|
|
286
|
+
|
|
287
|
+
case NodeType.SIDENOTE:
|
|
288
|
+
this.placeItem(node);
|
|
289
|
+
break;
|
|
290
|
+
|
|
291
|
+
case NodeType.TEXTBOX:
|
|
292
|
+
case NodeType.FILL_TEXTBOX: {
|
|
293
|
+
this.placeItem(node);
|
|
294
|
+
const height = parseInt(node.options?.height || node.options?.value || '1', 10);
|
|
295
|
+
this.advanceRows(height);
|
|
296
|
+
break;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
case NodeType.MATH:
|
|
300
|
+
case NodeType.SET_INDENT:
|
|
301
|
+
this.placeItem(node);
|
|
302
|
+
break;
|
|
303
|
+
|
|
304
|
+
default:
|
|
305
|
+
if (node.children && node.children.length > 0) {
|
|
306
|
+
this.walkChildren(node.children);
|
|
307
|
+
}
|
|
308
|
+
break;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Walk content block — separate floats from inline content.
|
|
314
|
+
*/
|
|
315
|
+
walkContentBlock(node) {
|
|
316
|
+
for (const child of node.children) {
|
|
317
|
+
if (child.type === NodeType.MEIPI || child.type === NodeType.PIZHU || child.type === NodeType.STAMP) {
|
|
318
|
+
this.currentPage.floats.push(child);
|
|
319
|
+
} else {
|
|
320
|
+
this.walkNode(child);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Walk a paragraph node.
|
|
327
|
+
* Emits start/end markers so the renderer can wrap the content with indent.
|
|
328
|
+
* Walks children individually so they can span page boundaries.
|
|
329
|
+
*/
|
|
330
|
+
walkParagraph(node) {
|
|
331
|
+
const indent = parseInt(node.options?.indent || '0', 10);
|
|
332
|
+
const prevIndent = this.currentIndent;
|
|
333
|
+
this.currentIndent = indent;
|
|
334
|
+
|
|
335
|
+
this.placeMarker(LayoutMarker.PARAGRAPH_START, { paragraphNode: node });
|
|
336
|
+
this.walkChildren(node.children);
|
|
337
|
+
this.placeMarker(LayoutMarker.PARAGRAPH_END);
|
|
338
|
+
|
|
339
|
+
this.currentIndent = prevIndent;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Walk LIST node — emits start/end markers and walks children.
|
|
344
|
+
* Tracks whether first item needs advanceColumn or not.
|
|
345
|
+
*/
|
|
346
|
+
walkList(node) {
|
|
347
|
+
this.placeMarker(LayoutMarker.LIST_START);
|
|
348
|
+
let first = true;
|
|
349
|
+
for (const child of node.children) {
|
|
350
|
+
if (child.type === NodeType.LIST_ITEM) {
|
|
351
|
+
this.walkListItem(child, first);
|
|
352
|
+
first = false;
|
|
353
|
+
} else {
|
|
354
|
+
this.walkNode(child);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
this.placeMarker(LayoutMarker.LIST_END);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Walk LIST_ITEM node — emits markers. Advances column for non-first items.
|
|
362
|
+
*/
|
|
363
|
+
walkListItem(node, isFirst = false) {
|
|
364
|
+
if (!isFirst) {
|
|
365
|
+
this.advanceColumn();
|
|
366
|
+
}
|
|
367
|
+
this.placeMarker(LayoutMarker.LIST_ITEM_START);
|
|
368
|
+
this.walkChildren(node.children);
|
|
369
|
+
this.placeMarker(LayoutMarker.LIST_ITEM_END);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Walk TEXT node — advance cursor row by character count.
|
|
374
|
+
*/
|
|
375
|
+
walkText(node) {
|
|
376
|
+
const chars = [...(node.value || '')];
|
|
377
|
+
this.placeItem(node);
|
|
378
|
+
this.advanceRows(chars.length);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Advance cursor rows by counting text in a node (for compound nodes).
|
|
383
|
+
*/
|
|
384
|
+
advanceRowsByNodeText(node) {
|
|
385
|
+
const text = getPlainText(node.children || []);
|
|
386
|
+
const len = [...text].length;
|
|
387
|
+
this.advanceRows(len);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Walk jiazhu node. Pre-compute segments based on remaining column space.
|
|
392
|
+
* Each segment is placed as a separate item so page breaks work correctly.
|
|
393
|
+
*/
|
|
394
|
+
walkJiazhu(node) {
|
|
395
|
+
const hasComplexChildren = node.children.some(c => c.type !== NodeType.TEXT);
|
|
396
|
+
const text = getPlainText(node.children);
|
|
397
|
+
const align = node.options?.align || 'outward';
|
|
398
|
+
const maxPerCol = this.effectiveRows;
|
|
399
|
+
|
|
400
|
+
const remaining = maxPerCol - this.currentRow;
|
|
401
|
+
const firstMax = remaining > 0 && remaining < maxPerCol ? remaining : maxPerCol;
|
|
402
|
+
|
|
403
|
+
if (hasComplexChildren) {
|
|
404
|
+
// Complex children: place as single item, advance by half chars
|
|
405
|
+
this.placeItem(node, { jiazhuSegments: null });
|
|
406
|
+
const totalChars = [...text].length;
|
|
407
|
+
this.advanceRows(Math.ceil(totalChars / 2));
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
const jiazhuSegments = splitJiazhuMulti(text, maxPerCol, align, firstMax);
|
|
412
|
+
|
|
413
|
+
if (jiazhuSegments.length <= 1) {
|
|
414
|
+
// Single segment: place and advance
|
|
415
|
+
this.placeItem(node, { jiazhuSegments });
|
|
416
|
+
const totalChars = [...text].length;
|
|
417
|
+
this.advanceRows(Math.ceil(totalChars / 2));
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// Multi-segment: place each segment as a separate item so page breaks work.
|
|
422
|
+
// First segment uses remaining space in current column.
|
|
423
|
+
this.placeItem(node, {
|
|
424
|
+
jiazhuSegments: [jiazhuSegments[0]],
|
|
425
|
+
jiazhuSegmentIndex: 0,
|
|
426
|
+
jiazhuTotalSegments: jiazhuSegments.length,
|
|
427
|
+
});
|
|
428
|
+
this.advanceRows(firstMax);
|
|
429
|
+
|
|
430
|
+
// Middle and last segments each fill a full column (or partial for last)
|
|
431
|
+
for (let i = 1; i < jiazhuSegments.length; i++) {
|
|
432
|
+
const seg = jiazhuSegments[i];
|
|
433
|
+
const segRows = Math.max([...seg.col1].length, [...seg.col2].length);
|
|
434
|
+
this.placeItem(node, {
|
|
435
|
+
jiazhuSegments: [seg],
|
|
436
|
+
jiazhuSegmentIndex: i,
|
|
437
|
+
jiazhuTotalSegments: jiazhuSegments.length,
|
|
438
|
+
});
|
|
439
|
+
this.advanceRows(segRows);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// ---------------------------------------------------------------------------
|
|
445
|
+
// Public API
|
|
446
|
+
// ---------------------------------------------------------------------------
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Run grid layout on an AST, returning a LayoutResult.
|
|
450
|
+
*
|
|
451
|
+
* @param {object} ast Parsed document AST
|
|
452
|
+
* @returns {LayoutResult}
|
|
453
|
+
*/
|
|
454
|
+
export function layout(ast) {
|
|
455
|
+
const templateId = resolveTemplateId(ast);
|
|
456
|
+
const { nRows, nCols } = getGridConfig(templateId);
|
|
457
|
+
const engine = new GridLayoutEngine(nRows, nCols);
|
|
458
|
+
|
|
459
|
+
// Only layout 'body' nodes — skip preamble paragraphBreaks etc.
|
|
460
|
+
for (const child of ast.children) {
|
|
461
|
+
if (child.type === 'body') {
|
|
462
|
+
engine.walkNode(child);
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// Finalize: ensure last page has halfBoundary
|
|
467
|
+
const lastPage = engine.currentPage;
|
|
468
|
+
if (lastPage.halfBoundary === null) {
|
|
469
|
+
lastPage.halfBoundary = lastPage.items.length;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
const meta = {
|
|
473
|
+
title: ast.title || '',
|
|
474
|
+
chapter: ast.chapter || '',
|
|
475
|
+
setupCommands: ast.setupCommands || [],
|
|
476
|
+
};
|
|
477
|
+
|
|
478
|
+
return {
|
|
479
|
+
pages: engine.pages,
|
|
480
|
+
gridConfig: { nRows, nCols },
|
|
481
|
+
templateId,
|
|
482
|
+
meta,
|
|
483
|
+
};
|
|
484
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AST node types for the document model.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export const NodeType = {
|
|
6
|
+
DOCUMENT: 'document',
|
|
7
|
+
CONTENT_BLOCK: 'contentBlock',
|
|
8
|
+
PARAGRAPH: 'paragraph',
|
|
9
|
+
TEXT: 'text',
|
|
10
|
+
NEWLINE: 'newline',
|
|
11
|
+
JIAZHU: 'jiazhu',
|
|
12
|
+
SIDENOTE: 'sidenote',
|
|
13
|
+
MEIPI: 'meipi',
|
|
14
|
+
PIZHU: 'pizhu',
|
|
15
|
+
TEXTBOX: 'textbox',
|
|
16
|
+
FILL_TEXTBOX: 'fillTextbox',
|
|
17
|
+
SPACE: 'space',
|
|
18
|
+
COLUMN_BREAK: 'columnBreak',
|
|
19
|
+
TAITOU: 'taitou',
|
|
20
|
+
NUOTAI: 'nuotai',
|
|
21
|
+
SET_INDENT: 'setIndent',
|
|
22
|
+
EMPHASIS: 'emphasis',
|
|
23
|
+
PROPER_NAME: 'properName',
|
|
24
|
+
BOOK_TITLE: 'bookTitle',
|
|
25
|
+
INVERTED: 'inverted',
|
|
26
|
+
OCTAGON: 'octagon',
|
|
27
|
+
CIRCLED: 'circled',
|
|
28
|
+
INVERTED_OCTAGON: 'invertedOctagon',
|
|
29
|
+
FIX: 'fix',
|
|
30
|
+
DECORATE: 'decorate',
|
|
31
|
+
LIST: 'list',
|
|
32
|
+
LIST_ITEM: 'listItem',
|
|
33
|
+
SETUP: 'setup',
|
|
34
|
+
STAMP: 'stamp',
|
|
35
|
+
MATH: 'math',
|
|
36
|
+
PARAGRAPH_BREAK: 'paragraphBreak',
|
|
37
|
+
UNKNOWN: 'unknown',
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Create a node with the given type and properties.
|
|
42
|
+
*/
|
|
43
|
+
export function createNode(type, props = {}) {
|
|
44
|
+
return { type, children: [], ...props };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Parse a key=value string into an object.
|
|
49
|
+
* Handles nested braces like color={180, 95, 75}.
|
|
50
|
+
*/
|
|
51
|
+
export function parseKeyValue(str) {
|
|
52
|
+
if (!str || !str.trim()) return {};
|
|
53
|
+
|
|
54
|
+
const result = {};
|
|
55
|
+
let depth = 0;
|
|
56
|
+
let currentKey = '';
|
|
57
|
+
let currentValue = '';
|
|
58
|
+
let inValue = false;
|
|
59
|
+
|
|
60
|
+
for (let i = 0; i < str.length; i++) {
|
|
61
|
+
const ch = str[i];
|
|
62
|
+
|
|
63
|
+
if (ch === '{') depth++;
|
|
64
|
+
if (ch === '}') depth--;
|
|
65
|
+
|
|
66
|
+
if (depth === 0 && ch === ',') {
|
|
67
|
+
if (currentKey.trim()) {
|
|
68
|
+
result[currentKey.trim()] = inValue ? currentValue.trim() : 'true';
|
|
69
|
+
}
|
|
70
|
+
currentKey = '';
|
|
71
|
+
currentValue = '';
|
|
72
|
+
inValue = false;
|
|
73
|
+
continue;
|
|
74
|
+
} else if (depth === 0 && ch === '=' && !inValue) {
|
|
75
|
+
inValue = true;
|
|
76
|
+
} else if (inValue) {
|
|
77
|
+
currentValue += ch;
|
|
78
|
+
} else {
|
|
79
|
+
currentKey += ch;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Last pair
|
|
84
|
+
if (currentKey.trim()) {
|
|
85
|
+
if (inValue) {
|
|
86
|
+
result[currentKey.trim()] = currentValue.trim();
|
|
87
|
+
} else {
|
|
88
|
+
// Boolean flag: key without value
|
|
89
|
+
result[currentKey.trim()] = 'true';
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return result;
|
|
94
|
+
}
|