@knowvah/dot-engine 2.6.2 → 2.6.4

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/common/htmltable-lex.d.ts +38 -0
  2. package/dist/common/htmltable-lex.d.ts.map +1 -1
  3. package/dist/common/htmltable-parse-attrs.d.ts +41 -0
  4. package/dist/common/htmltable-parse-attrs.d.ts.map +1 -0
  5. package/dist/common/htmltable-parse.d.ts +3 -0
  6. package/dist/common/htmltable-parse.d.ts.map +1 -1
  7. package/dist/common/htmltable-pos.d.ts +17 -0
  8. package/dist/common/htmltable-pos.d.ts.map +1 -1
  9. package/dist/common/htmltable.d.ts +13 -1
  10. package/dist/common/htmltable.d.ts.map +1 -1
  11. package/dist/common/make-label.d.ts.map +1 -1
  12. package/dist/common/spline-midpoint.d.ts +8 -0
  13. package/dist/common/spline-midpoint.d.ts.map +1 -1
  14. package/dist/index.js +621 -323
  15. package/dist/index.js.map +4 -4
  16. package/dist/label/node.d.ts +7 -1
  17. package/dist/label/node.d.ts.map +1 -1
  18. package/dist/layout/circo/position-helpers.d.ts +15 -3
  19. package/dist/layout/circo/position-helpers.d.ts.map +1 -1
  20. package/dist/layout/circo/position.d.ts +17 -1
  21. package/dist/layout/circo/position.d.ts.map +1 -1
  22. package/dist/layout/dot/ortho-adapter.d.ts.map +1 -1
  23. package/dist/layout/dot/splines-flat-labeled.d.ts.map +1 -1
  24. package/dist/layout/neato/multispline.d.ts.map +1 -1
  25. package/dist/layout/pack/poly-pack.d.ts +2 -18
  26. package/dist/layout/pack/poly-pack.d.ts.map +1 -1
  27. package/dist/pathplan/shortest.d.ts.map +1 -1
  28. package/dist/render.js +774 -476
  29. package/dist/render.js.map +4 -4
  30. package/package.json +1 -1
  31. package/src/common/htmltable-lex.ts +265 -5
  32. package/src/common/htmltable-parse-attrs.ts +166 -0
  33. package/src/common/htmltable-parse.ts +262 -242
  34. package/src/common/htmltable-pos.ts +37 -38
  35. package/src/common/htmltable.ts +139 -45
  36. package/src/common/make-label.ts +19 -3
  37. package/src/common/spline-midpoint.ts +30 -9
  38. package/src/label/node.ts +8 -2
  39. package/src/layout/circo/position-helpers.ts +28 -17
  40. package/src/layout/circo/position.ts +28 -7
  41. package/src/layout/dot/__fixtures__/2796-flat-labeled-lost.dot +161 -0
  42. package/src/layout/dot/ortho-adapter.ts +20 -9
  43. package/src/layout/dot/splines-flat-labeled.ts +3 -1
  44. package/src/layout/neato/multispline.ts +17 -5
  45. package/src/layout/pack/poly-pack.ts +5 -31
  46. package/src/pathplan/shortest.ts +15 -2
@@ -8,63 +8,27 @@
8
8
  */
9
9
 
10
10
  import type {
11
- HtmlAlign,
12
11
  HtmlCell,
13
12
  HtmlCellContent,
14
- HtmlHR,
15
13
  HtmlImage,
16
14
  HtmlLabel,
17
15
  HtmlRow,
18
16
  HtmlTable,
19
17
  HtmlText,
20
18
  HtmlTextItem,
21
- HtmlVAlign,
22
19
  Token,
23
20
  OpenToken,
24
21
  } from './htmltable-types.js';
22
+ import { HtmlParseError } from './htmltable-types.js';
23
+ import { htmlErrorAtToken, tokenizeDeferred } from './htmltable-lex.js';
25
24
  import {
26
- HtmlParseError,
27
- BORDER_LEFT,
28
- BORDER_TOP,
29
- BORDER_RIGHT,
30
- BORDER_BOTTOM,
31
- BORDER_MASK,
32
- } from './htmltable-types.js';
33
- import { tokenize } from './htmltable-lex.js';
34
- import { htmlEntityUTF8 } from './html-entities.js';
35
-
36
- /** Decode XML/HTML entities in an anchor attribute value (href/target), as C's
37
- * HTML parser does. The stored value is the real character (`&` → `&`), so
38
- * imap emits it raw and cmapx re-escapes it. Undefined passes through. */
39
- function anchorAttr(v: string | undefined): string | undefined {
40
- return v === undefined ? undefined : htmlEntityUTF8(v);
41
- }
42
-
43
- // ---------------------------------------------------------------------------
44
- // Attribute helpers
45
- // ---------------------------------------------------------------------------
46
-
47
- const parseAlign = (v: string): HtmlAlign | undefined => {
48
- const u = v.toUpperCase();
49
- if (u === 'LEFT') return 'left';
50
- if (u === 'RIGHT') return 'right';
51
- if (u === 'CENTER') return 'center';
52
- return undefined;
53
- };
54
-
55
- const parseVAlign = (v: string): HtmlVAlign | undefined => {
56
- const u = v.toUpperCase();
57
- if (u === 'TOP') return 'top';
58
- if (u === 'BOTTOM') return 'bottom';
59
- if (u === 'MIDDLE') return 'middle';
60
- return undefined;
61
- };
62
-
63
- const num = (s: string | undefined): number | undefined => {
64
- if (s === undefined) return undefined;
65
- const n = parseInt(s, 10);
66
- return isNaN(n) ? undefined : n;
67
- };
25
+ buildCell,
26
+ buildTable,
27
+ num,
28
+ parseAlign,
29
+ parseColumns,
30
+ parseRows,
31
+ } from './htmltable-parse-attrs.js';
68
32
 
69
33
  // ---------------------------------------------------------------------------
70
34
  // Font-stack — mirrors pushFont / popFont in htmlparse.y
@@ -115,8 +79,33 @@ const fontItemProps = (f: FontState): Partial<HtmlTextItem> => {
115
79
  interface ParseState {
116
80
  tokens: Token[];
117
81
  pos: number;
82
+ /** Lexical error found after the last token: C reports it when the parser asks for more. */
83
+ pending?: HtmlParseError;
118
84
  }
119
85
 
86
+ /** bison's default message for an unexpected token. @see lib/common/htmlparse.y */
87
+ const MSG_SYNTAX_ERROR = 'syntax error';
88
+ /** @see lib/common/htmlparse.y:288 (table rule) */
89
+ const MSG_TEXT_BEFORE_TABLE = 'Syntax error: non-space string used before <TABLE>';
90
+ /** @see lib/common/htmlparse.y:298 (table rule) */
91
+ const MSG_TEXT_AFTER_TABLE = 'Syntax error: non-space string used after </TABLE>';
92
+
93
+ /**
94
+ * C's `nonSpace` on the lexer's text, which has already dropped control
95
+ * characters; the port's tokens still carry tab, LF and CR.
96
+ * @see lib/common/htmlparse.y:nonSpace
97
+ */
98
+ const nonSpace = (text: string): boolean => /[^ \t\n\r]/.test(text);
99
+
100
+ /**
101
+ * The parse failure C reports through `htmlerror` when the lexer last returned
102
+ * `tok`. At end of input the deferred lexical error is what C would hit next.
103
+ * A token without a recorded chunk yields a plain, silent HtmlParseError.
104
+ * @see lib/common/htmllex.c:htmlerror
105
+ */
106
+ const parseError = (s: ParseState, tok: Token | undefined, message: string): HtmlParseError =>
107
+ (tok === undefined ? s.pending : htmlErrorAtToken(message, tok)) ?? new HtmlParseError(message);
108
+
120
109
  const peek = (s: ParseState): Token | undefined => s.tokens[s.pos];
121
110
 
122
111
  const consume = (s: ParseState): Token => {
@@ -144,14 +133,29 @@ const inlineFontState = (tag: string, _attrs: Record<string, string>): FontState
144
133
  return fs;
145
134
  };
146
135
 
136
+ /**
137
+ * `br: T_br T_end_br`, likewise image, HR and VR: the open tag must be
138
+ * followed at once by its close (`<BR/>` yields both); anything else is a
139
+ * syntax error at that token.
140
+ * @see lib/common/htmlparse.y:br, image
141
+ */
142
+ const consumeClose = (s: ParseState, tag: string): void => {
143
+ const nxt = peek(s);
144
+ if (nxt === undefined) return;
145
+ if (nxt.type === 'close' && nxt.tag === tag) {
146
+ consume(s);
147
+ return;
148
+ }
149
+ throw parseError(s, nxt, MSG_SYNTAX_ERROR);
150
+ };
151
+
147
152
  const applyBrTag = (
148
153
  s: ParseState, t: OpenToken, fontStack: FontState[], items: HtmlTextItem[],
149
154
  ): void => {
150
155
  consume(s);
151
156
  const font = fontStack[fontStack.length - 1] ?? {};
152
157
  items.push({ br: true, brAlign: parseAlign(t.attrs['align'] ?? ''), ...fontItemProps(font) });
153
- const nxt = peek(s);
154
- if (nxt?.type === 'close' && nxt.tag === 'BR') consume(s);
158
+ consumeClose(s, 'BR');
155
159
  };
156
160
 
157
161
  const applyFontTag = (
@@ -219,13 +223,6 @@ export const parseText = (s: ParseState, fontStack: FontState[]): HtmlText => {
219
223
  // Cell content helpers — mirrors htmlparse.y cell rule
220
224
  // ---------------------------------------------------------------------------
221
225
 
222
- const parseHrContent = (s: ParseState): HtmlHR => {
223
- consume(s);
224
- const nxt = peek(s);
225
- if (nxt?.type === 'close' && nxt.tag === 'HR') consume(s);
226
- return { kind: 'hr' };
227
- };
228
-
229
226
  const parseImgContent = (
230
227
  s: ParseState, t: OpenToken, cellAttrs: Record<string, string>,
231
228
  ): HtmlImage => {
@@ -235,30 +232,84 @@ const parseImgContent = (
235
232
  src: cellAttrs['src'] ?? t.attrs['src'] ?? '',
236
233
  scale: t.attrs['scale'],
237
234
  };
238
- const nxt = peek(s);
239
- if (nxt?.type === 'close' && nxt.tag === 'IMG') consume(s);
235
+ consumeClose(s, 'IMG');
240
236
  return img;
241
237
  };
242
238
 
243
- const parseCellItem = (
244
- s: ParseState, t: Token, cellAttrs: Record<string, string>, fontStack: FontState[],
239
+ /** What a cell holds so far: C's cell has exactly one child. */
240
+ interface CellScan {
241
+ kind: 'none' | 'text' | 'table' | 'image';
242
+ /** Non-space text before the nested table (checked when the table opens). */
243
+ textNonSpace: boolean;
244
+ /** Non-space text after the nested table (checked when the run ends). */
245
+ afterNonSpace: boolean;
246
+ }
247
+
248
+ /** `table: opt_space T_table ... T_end_table opt_space` inside a cell. */
249
+ const scanCellTable = (
250
+ s: ParseState, t: Token, fontStack: FontState[], st: CellScan,
245
251
  ): HtmlCellContent[] => {
246
- if (t.type === 'open' && t.tag === 'TABLE') return [parseTable(s, fontStack)];
247
- if (t.type === 'open' && t.tag === 'IMG') return [parseImgContent(s, t, cellAttrs)];
248
- if (t.type === 'open' && t.tag === 'HR') return [parseHrContent(s)];
252
+ if (st.kind === 'image' || st.kind === 'table') throw parseError(s, t, MSG_SYNTAX_ERROR);
253
+ if (st.textNonSpace) throw parseError(s, t, MSG_TEXT_BEFORE_TABLE);
254
+ const tbl = parseTable(s, fontStack);
255
+ st.kind = 'table';
256
+ return [tbl];
257
+ };
258
+
259
+ /** Text run (strings and inline tags) of a cell holding text so far. */
260
+ const scanCellText = (s: ParseState, t: Token, fontStack: FontState[], st: CellScan): HtmlCellContent[] => {
261
+ const start = s.pos;
249
262
  const txt = parseText(s, fontStack);
263
+ if (s.pos === start) throw parseError(s, t, MSG_SYNTAX_ERROR);
264
+ if (txt.items.some((i) => i.text !== undefined && nonSpace(i.text))) st.textNonSpace = true;
265
+ st.kind = 'text';
250
266
  return txt.items.length > 0 ? [txt] : [];
251
267
  };
252
268
 
269
+ /** Only the blanks of the table rule's trailing opt_space may follow a nested table. */
270
+ const scanAfterTable = (s: ParseState, t: Token, st: CellScan): HtmlCellContent[] => {
271
+ if (t.type !== 'text') throw parseError(s, t, st.afterNonSpace ? MSG_TEXT_AFTER_TABLE : MSG_SYNTAX_ERROR);
272
+ consume(s);
273
+ if (nonSpace(t.value)) st.afterNonSpace = true;
274
+ return [];
275
+ };
276
+
277
+ /** `image` as the cell's only child. */
278
+ const scanCellImage = (
279
+ s: ParseState, t: OpenToken, cellAttrs: Record<string, string>, st: CellScan,
280
+ ): HtmlCellContent[] => {
281
+ if (st.kind !== 'none') throw parseError(s, t, MSG_SYNTAX_ERROR);
282
+ st.kind = 'image';
283
+ return [parseImgContent(s, t, cellAttrs)];
284
+ };
285
+
286
+ /** One step of `cell: T_cell (fonttable | fonttext | image) T_end_cell`. */
287
+ const scanCellItem = (
288
+ s: ParseState, t: Token, cellAttrs: Record<string, string>, fontStack: FontState[], st: CellScan,
289
+ ): HtmlCellContent[] => {
290
+ if (st.kind === 'table') return scanAfterTable(s, t, st);
291
+ if (t.type === 'open' && t.tag === 'TABLE') return scanCellTable(s, t, fontStack, st);
292
+ if (t.type === 'open' && t.tag === 'IMG') return scanCellImage(s, t, cellAttrs, st);
293
+ if (st.kind === 'image') throw parseError(s, t, MSG_SYNTAX_ERROR);
294
+ return scanCellText(s, t, fontStack, st);
295
+ };
296
+
297
+ /** True at the cell's own `</TD>`; any other close tag is a syntax error. */
298
+ const endsCell = (s: ParseState, t: Token, st: CellScan): boolean => {
299
+ if (t.type !== 'close') return false;
300
+ const afterText = st.kind === 'table' && st.afterNonSpace;
301
+ if (t.tag === 'TD' && !afterText) return true;
302
+ throw parseError(s, t, afterText ? MSG_TEXT_AFTER_TABLE : MSG_SYNTAX_ERROR);
303
+ };
304
+
253
305
  const parseCellContent = (
254
306
  s: ParseState, cellAttrs: Record<string, string>, fontStack: FontState[],
255
307
  ): HtmlCellContent[] => {
256
308
  const content: HtmlCellContent[] = [];
257
- while (s.pos < s.tokens.length) {
258
- const t = peek(s);
259
- if (!t) break;
260
- if (t.type === 'close' && t.tag === 'TD') break;
261
- content.push(...parseCellItem(s, t, cellAttrs, fontStack));
309
+ const st: CellScan = { kind: 'none', textNonSpace: false, afterNonSpace: false };
310
+ for (let t = peek(s); t !== undefined; t = peek(s)) {
311
+ if (endsCell(s, t, st)) break;
312
+ content.push(...scanCellItem(s, t, cellAttrs, fontStack, st));
262
313
  }
263
314
  const nxt = peek(s);
264
315
  if (nxt?.type === 'close' && nxt.tag === 'TD') consume(s);
@@ -270,86 +321,6 @@ const parseCellContent = (
270
321
  return content;
271
322
  };
272
323
 
273
- const parseFixedSize = (a: Record<string, string>): true | undefined =>
274
- a['fixedsize']?.toUpperCase() === 'TRUE' ? true : undefined;
275
-
276
- const parseTitle = (a: Record<string, string>): string | undefined =>
277
- a['title'] !== undefined ? a['title'] : a['tooltip'];
278
-
279
- /**
280
- * Parse SIDES attribute into a bitmask matching C sidesfn().
281
- * Unrecognised chars are silently skipped (C issues a warning but continues).
282
- * C guard: if (flags != BORDER_MASK) p->flags |= flags — the full-mask case
283
- * is a no-op (means "all sides", the default), so we return undefined for it.
284
- * @see lib/common/htmllex.c:sidesfn
285
- */
286
- const parseSides = (v: string | undefined): number | undefined => {
287
- if (v === undefined) return undefined;
288
- let flags = 0;
289
- for (const c of v.toLowerCase()) {
290
- if (c === 'l') flags |= BORDER_LEFT;
291
- else if (c === 't') flags |= BORDER_TOP;
292
- else if (c === 'r') flags |= BORDER_RIGHT;
293
- else if (c === 'b') flags |= BORDER_BOTTOM;
294
- }
295
- // C: if (flags != BORDER_MASK) p->flags |= flags
296
- if (flags === 0 || flags === BORDER_MASK) return undefined;
297
- return flags;
298
- };
299
-
300
- /**
301
- * Parse GRADIENTANGLE attribute — integer in [0, 360].
302
- * Out-of-range or non-integer values are ignored (C issues a warning).
303
- * @see lib/common/htmllex.c:gradientanglefn
304
- */
305
- const parseGradientAngle = (v: string | undefined): number | undefined => {
306
- if (v === undefined) return undefined;
307
- const n = parseInt(v, 10);
308
- if (isNaN(n) || String(n) !== v.trim()) return undefined;
309
- if (n < 0 || n > 360) return undefined;
310
- return n;
311
- };
312
-
313
- /**
314
- * Parse COLUMNS attribute — only "*" is valid (sets vrule=true).
315
- * @see lib/common/htmllex.c:columnsfn
316
- */
317
- const parseColumns = (v: string | undefined): true | undefined =>
318
- v === '*' ? true : undefined;
319
-
320
- /**
321
- * Parse ROWS attribute — only "*" is valid (sets hrule=true).
322
- * @see lib/common/htmllex.c:rowsfn
323
- */
324
- const parseRows = (v: string | undefined): true | undefined =>
325
- v === '*' ? true : undefined;
326
-
327
- const buildCell = (content: HtmlCellContent[], a: Record<string, string>): HtmlCell => ({
328
- kind: 'cell',
329
- content,
330
- port: a['port'],
331
- align: parseAlign(a['align'] !== undefined ? a['align'] : ''),
332
- valign: parseVAlign(a['valign'] !== undefined ? a['valign'] : ''),
333
- balign: parseAlign(a['balign'] !== undefined ? a['balign'] : ''),
334
- bgcolor: a['bgcolor'],
335
- color: a['color'],
336
- border: num(a['border']),
337
- cellpadding: num(a['cellpadding']),
338
- cellspacing: num(a['cellspacing']),
339
- sides: parseSides(a['sides']),
340
- gradientangle: parseGradientAngle(a['gradientangle']),
341
- style: a['style'],
342
- width: num(a['width']),
343
- height: num(a['height']),
344
- fixedsize: parseFixedSize(a),
345
- colspan: num(a['colspan']),
346
- rowspan: num(a['rowspan']),
347
- href: anchorAttr(a['href']),
348
- title: parseTitle(a),
349
- target: anchorAttr(a['target']),
350
- id: a['id'],
351
- });
352
-
353
324
  /** @see lib/common/htmlparse.y:cell rule / lib/common/htmllex.c:mkCell */
354
325
  const parseCell = (
355
326
  s: ParseState, attrs: Record<string, string>, fontStack: FontState[],
@@ -359,72 +330,98 @@ const parseCell = (
359
330
  // parseRow — parses <TR>...</TR>
360
331
  // ---------------------------------------------------------------------------
361
332
 
362
- const processRowToken = (
363
- s: ParseState, t: Token, cells: HtmlCell[], fontStack: FontState[],
364
- ): void => {
333
+ /**
334
+ * `cells : cell | cells cell | cells VR cell`: VR marks the PRECEDING cell
335
+ * as vertically ruled and must sit between two cells.
336
+ * @see lib/common/htmlparse.y:329
337
+ */
338
+ const parseRowVr = (s: ParseState, t: Token, cells: HtmlCell[]): void => {
339
+ const prev = cells[cells.length - 1];
340
+ if (prev === undefined) throw parseError(s, t, MSG_SYNTAX_ERROR);
341
+ consume(s);
342
+ consumeClose(s, 'VR');
343
+ prev.vruled = true;
344
+ };
345
+
346
+ interface RowScan {
347
+ cells: HtmlCell[];
348
+ /** A VR was seen: the next cell is required. */
349
+ needCell: boolean;
350
+ }
351
+
352
+ /** A TD, or a VR between cells. */
353
+ const scanRowCell = (s: ParseState, t: Token, fontStack: FontState[], rs: RowScan): void => {
365
354
  if (t.type === 'open' && t.tag === 'TD') {
366
355
  consume(s);
367
- cells.push(parseCell(s, t.attrs, fontStack));
368
- return;
356
+ rs.cells.push(parseCell(s, t.attrs, fontStack));
357
+ rs.needCell = false;
358
+ } else if (t.type === 'open' && t.tag === 'VR' && !rs.needCell) {
359
+ parseRowVr(s, t, rs.cells);
360
+ rs.needCell = true;
361
+ } else {
362
+ throw parseError(s, t, MSG_SYNTAX_ERROR);
369
363
  }
370
- if (t.type === 'open' && t.tag === 'VR') {
371
- consume(s);
372
- const nxt = peek(s);
373
- if (nxt?.type === 'close' && nxt.tag === 'VR') consume(s);
374
- // C grammar: "cells VR cell" — VR marks the PRECEDING cell as
375
- // vertically ruled; VR without a preceding cell is a syntax error.
376
- // @see lib/common/htmlparse.y:329
377
- const prev = cells[cells.length - 1];
378
- if (prev === undefined) throw new HtmlParseError('VR');
379
- prev.vruled = true;
380
- return;
364
+ };
365
+
366
+ /** One token of `row: T_row cells T_end_row`; true at the row's `</TR>`. */
367
+ const scanRowToken = (s: ParseState, t: Token, fontStack: FontState[], rs: RowScan): boolean => {
368
+ if (t.type === 'text') { consume(s); return false; } // not in a cell: ignored
369
+ if (t.type !== 'close' || t.tag !== 'TR') {
370
+ scanRowCell(s, t, fontStack, rs);
371
+ return false;
381
372
  }
382
- consume(s);
373
+ if (rs.cells.length === 0 || rs.needCell) throw parseError(s, t, MSG_SYNTAX_ERROR);
374
+ return true;
383
375
  };
384
376
 
385
377
  /** @see lib/common/htmlparse.y:row + cells rules */
386
378
  const parseRow = (s: ParseState, fontStack: FontState[]): HtmlRow => {
387
- const cells: HtmlCell[] = [];
388
- while (s.pos < s.tokens.length) {
389
- const t = peek(s);
390
- if (!t) break;
391
- if (t.type === 'close' && t.tag === 'TR') break;
392
- processRowToken(s, t, cells, fontStack);
379
+ const rs: RowScan = { cells: [], needCell: false };
380
+ for (let t = peek(s); t !== undefined; t = peek(s)) {
381
+ if (scanRowToken(s, t, fontStack, rs)) break;
393
382
  }
394
383
  const nxt = peek(s);
395
384
  if (nxt?.type === 'close' && nxt.tag === 'TR') consume(s);
396
- return { cells };
385
+ return { cells: rs.cells };
397
386
  };
398
387
 
399
388
  // ---------------------------------------------------------------------------
400
389
  // parseTable — parses <TABLE>...</TABLE>
401
390
  // ---------------------------------------------------------------------------
402
391
 
403
- const buildTable = (rows: HtmlRow[], a: Record<string, string>): HtmlTable => ({
404
- kind: 'table' as const,
405
- rows,
406
- border: num(a['border']),
407
- cellborder: num(a['cellborder']),
408
- cellspacing: num(a['cellspacing']),
409
- cellpadding: num(a['cellpadding']),
410
- bgcolor: a['bgcolor'],
411
- color: a['color'],
412
- style: a['style'],
413
- align: parseAlign(a['align'] !== undefined ? a['align'] : ''),
414
- valign: parseVAlign(a['valign'] !== undefined ? a['valign'] : ''),
415
- fixedsize: parseFixedSize(a),
416
- width: num(a['width']),
417
- height: num(a['height']),
418
- href: anchorAttr(a['href']),
419
- title: parseTitle(a),
420
- target: anchorAttr(a['target']),
421
- id: a['id'],
422
- port: a['port'],
423
- sides: parseSides(a['sides']),
424
- gradientangle: parseGradientAngle(a['gradientangle']),
425
- vrule: parseColumns(a['columns']),
426
- hrule: parseRows(a['rows']),
427
- });
392
+ interface TableScan {
393
+ rows: HtmlRow[];
394
+ /** An HR was seen: the next row is required. */
395
+ needRow: boolean;
396
+ hrule: boolean;
397
+ vrule: boolean;
398
+ }
399
+
400
+ /** `rows : row | rows row | rows HR row`: a TR, or an HR after a row. */
401
+ const scanTableRow = (s: ParseState, t: Token, fontStack: FontState[], ts: TableScan): void => {
402
+ if (t.type === 'open' && t.tag === 'TR') {
403
+ consume(s);
404
+ ts.rows.push(parseTableRow(s, fontStack, ts));
405
+ ts.needRow = false;
406
+ } else if (t.type === 'open' && t.tag === 'HR' && ts.rows.length > 0 && !ts.needRow) {
407
+ consumeRowHr(s, ts.rows);
408
+ ts.needRow = true;
409
+ } else {
410
+ throw parseError(s, t, MSG_SYNTAX_ERROR);
411
+ }
412
+ };
413
+
414
+ /** One token of a table body; true after the table's `</TABLE>`. */
415
+ const scanTableToken = (s: ParseState, t: Token, fontStack: FontState[], ts: TableScan): boolean => {
416
+ if (t.type === 'text') { consume(s); return false; } // not in a cell: ignored
417
+ if (t.type !== 'close' || t.tag !== 'TABLE') {
418
+ scanTableRow(s, t, fontStack, ts);
419
+ return false;
420
+ }
421
+ if (ts.rows.length === 0 || ts.needRow) throw parseError(s, t, MSG_SYNTAX_ERROR);
422
+ consume(s);
423
+ return true;
424
+ };
428
425
 
429
426
  /** @see lib/common/htmlparse.y:table rule / lib/common/htmllex.c:mkTbl */
430
427
  export const parseTable = (s: ParseState, fontStack: FontState[]): HtmlTable => {
@@ -432,25 +429,16 @@ export const parseTable = (s: ParseState, fontStack: FontState[]): HtmlTable =>
432
429
  if (open.type !== 'open' || open.tag !== 'TABLE') throw new HtmlParseError('TABLE');
433
430
  // C addRow: every row starts ruled when ROWS="*" (htmlparse.y addRow);
434
431
  // C setCell: every cell starts vruled when COLUMNS="*".
435
- const hrule = parseRows(open.attrs['rows']) === true;
436
- const vrule = parseColumns(open.attrs['columns']) === true;
437
- const rows: HtmlRow[] = [];
438
- while (s.pos < s.tokens.length) {
439
- const t = peek(s);
440
- if (!t) break;
441
- if (t.type === 'close' && t.tag === 'TABLE') { consume(s); break; }
442
- if (t.type === 'open' && t.tag === 'TR') {
443
- consume(s);
444
- rows.push(parseTableRow(s, fontStack, { hrule, vrule }));
445
- continue;
446
- }
447
- if (t.type === 'open' && t.tag === 'HR') {
448
- consumeRowHr(s, rows);
449
- continue;
450
- }
451
- consume(s);
432
+ const ts: TableScan = {
433
+ rows: [],
434
+ needRow: false,
435
+ hrule: parseRows(open.attrs['rows']) === true,
436
+ vrule: parseColumns(open.attrs['columns']) === true,
437
+ };
438
+ for (let t = peek(s); t !== undefined; t = peek(s)) {
439
+ if (scanTableToken(s, t, fontStack, ts)) break;
452
440
  }
453
- return buildTable(rows, open.attrs);
441
+ return buildTable(ts.rows, open.attrs);
454
442
  };
455
443
 
456
444
  /** Parse one row, applying table-level rule defaults. @see lib/common/htmlparse.y addRow/setCell */
@@ -470,11 +458,9 @@ const parseTableRow = (
470
458
  */
471
459
  const consumeRowHr = (s: ParseState, rows: HtmlRow[]): void => {
472
460
  consume(s);
473
- const nxt = peek(s);
474
- if (nxt?.type === 'close' && nxt.tag === 'HR') consume(s);
461
+ consumeClose(s, 'HR');
475
462
  const prev = rows[rows.length - 1];
476
- if (prev === undefined) throw new HtmlParseError('HR');
477
- prev.ruled = true;
463
+ if (prev !== undefined) prev.ruled = true;
478
464
  };
479
465
 
480
466
  // ---------------------------------------------------------------------------
@@ -497,14 +483,60 @@ const skipLeadingWs = (s: ParseState): void => {
497
483
  }
498
484
  };
499
485
 
486
+ /** After the top-level `</TABLE>`: blanks, then `</HTML>`. */
487
+ const finishTableLabel = (s: ParseState): void => {
488
+ let afterNonSpace = false;
489
+ for (let t = peek(s); t?.type === 'text'; t = peek(s)) {
490
+ consume(s);
491
+ if (nonSpace(t.value)) afterNonSpace = true;
492
+ }
493
+ const nxt = peek(s);
494
+ if (nxt === undefined) return;
495
+ if (afterNonSpace) throw parseError(s, nxt, MSG_TEXT_AFTER_TABLE);
496
+ if (nxt.type !== 'close' || nxt.tag !== 'HTML') throw parseError(s, nxt, MSG_SYNTAX_ERROR);
497
+ consume(s);
498
+ };
499
+
500
500
  const parseTableLabel = (s: ParseState, fontStack: FontState[]): HtmlLabel => {
501
501
  skipLeadingWs(s);
502
502
  const table = parseTable(s, fontStack);
503
- const nxt = peek(s);
504
- if (nxt?.type === 'close' && nxt.tag === 'HTML') consume(s);
503
+ finishTableLabel(s);
505
504
  return { kind: 'table', table };
506
505
  };
507
506
 
507
+ /** `html : T_html fonttext T_end_html`; a `<TABLE>` after text is the table rule's error. */
508
+ const parseTextLabel = (s: ParseState, fontStack: FontState[]): HtmlLabel => {
509
+ const texts: HtmlText[] = [];
510
+ const txt = parseText(s, fontStack);
511
+ if (txt.items.length > 0) texts.push(txt);
512
+ const nxt = peek(s);
513
+ if (nxt === undefined) return { kind: 'text', texts };
514
+ if (nxt.type === 'open' && nxt.tag === 'TABLE') {
515
+ // C's `table` rule aborts the whole label when non-space text precedes a
516
+ // top-level <TABLE>. Text that is only blanks means a font-wrapped table
517
+ // (`fonttable`), which this parser does not model: rejected without a
518
+ // message rather than with a wrong one.
519
+ // @see lib/common/htmlparse.y:288 (table rule nonSpace check)
520
+ if (txt.items.some((i) => i.text !== undefined && nonSpace(i.text))) {
521
+ throw parseError(s, nxt, MSG_TEXT_BEFORE_TABLE);
522
+ }
523
+ throw new HtmlParseError('TABLE');
524
+ }
525
+ if (nxt.type !== 'close' || nxt.tag !== 'HTML') throw parseError(s, nxt, MSG_SYNTAX_ERROR);
526
+ consume(s);
527
+ return { kind: 'text', texts };
528
+ };
529
+
530
+ /** Parse the token stream; the deferred lexical error wins once input runs out. */
531
+ const parseTokens = (s: ParseState): HtmlLabel => {
532
+ const fontStack: FontState[] = [{}];
533
+ const htmlOpen = consume(s);
534
+ if (htmlOpen.type !== 'open' || htmlOpen.tag !== 'HTML') throw new HtmlParseError('HTML');
535
+ const firstReal = findFirstRealToken(s.tokens, s.pos);
536
+ if (firstReal?.type === 'open' && firstReal.tag === 'TABLE') return parseTableLabel(s, fontStack);
537
+ return parseTextLabel(s, fontStack);
538
+ };
539
+
508
540
  /**
509
541
  * Parse an HTML-like label string.
510
542
  * Input is the content between the outer `<` `>` delimiters (already stripped).
@@ -512,28 +544,16 @@ const parseTableLabel = (s: ParseState, fontStack: FontState[]): HtmlLabel => {
512
544
  * @see lib/common/htmlparse.y:parseHTML
513
545
  */
514
546
  export const parseHtmlLabel = (src: string): HtmlLabel => {
515
- const wrapped = `<HTML>${src}</HTML>`;
516
- const tokens = tokenize(wrapped);
517
- const s: ParseState = { tokens, pos: 0 };
518
- const fontStack: FontState[] = [{}];
519
- const htmlOpen = consume(s);
520
- if (htmlOpen.type !== 'open' || htmlOpen.tag !== 'HTML') throw new HtmlParseError('HTML');
521
- const firstReal = findFirstRealToken(tokens, s.pos);
522
- if (firstReal?.type === 'open' && firstReal.tag === 'TABLE') {
523
- return parseTableLabel(s, fontStack);
524
- }
525
- const texts: HtmlText[] = [];
526
- const txt = parseText(s, fontStack);
527
- if (txt.items.length > 0) texts.push(txt);
528
- const nxt = peek(s);
529
- // C's `table` rule aborts the whole label when non-space text precedes a
530
- // top-level <TABLE> ("Syntax error: non-space string used before <TABLE>").
531
- // parseText stopped at the <TABLE>; reaching it here means there was leading
532
- // non-space text, so reject the label rather than leniently dropping the table.
533
- // @see lib/common/htmlparse.y:288 (table rule nonSpace check)
534
- if (nxt?.type === 'open' && nxt.tag === 'TABLE') {
535
- throw new HtmlParseError('non-space string used before <TABLE>');
547
+ const { tokens, error } = tokenizeDeferred(`<HTML>${src}</HTML>`);
548
+ const s: ParseState = { tokens, pos: 0, pending: error };
549
+ let label: HtmlLabel;
550
+ try {
551
+ label = parseTokens(s);
552
+ } catch (e) {
553
+ // Running off the truncated stream means C's lexer would have hit the error.
554
+ if (error !== undefined && s.pos >= tokens.length) throw error;
555
+ throw e;
536
556
  }
537
- if (nxt?.type === 'close' && nxt.tag === 'HTML') consume(s);
538
- return { kind: 'text', texts };
557
+ if (error !== undefined) throw error;
558
+ return label;
539
559
  };