@knowvah/dot-engine 2.6.4 → 2.6.6

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 (93) hide show
  1. package/dist/common/arrows-shapes-poly.d.ts.map +1 -1
  2. package/dist/common/bezier.d.ts +12 -0
  3. package/dist/common/bezier.d.ts.map +1 -0
  4. package/dist/common/htmltable-attr-warn.d.ts +41 -0
  5. package/dist/common/htmltable-attr-warn.d.ts.map +1 -0
  6. package/dist/common/htmltable-fontinfo.d.ts +42 -0
  7. package/dist/common/htmltable-fontinfo.d.ts.map +1 -0
  8. package/dist/common/htmltable-lex-expat.d.ts +63 -0
  9. package/dist/common/htmltable-lex-expat.d.ts.map +1 -0
  10. package/dist/common/htmltable-lex-units.d.ts +32 -0
  11. package/dist/common/htmltable-lex-units.d.ts.map +1 -0
  12. package/dist/common/htmltable-lex.d.ts +10 -2
  13. package/dist/common/htmltable-lex.d.ts.map +1 -1
  14. package/dist/common/htmltable-parse-attrs.d.ts +7 -5
  15. package/dist/common/htmltable-parse-attrs.d.ts.map +1 -1
  16. package/dist/common/htmltable-parse-font.d.ts +33 -0
  17. package/dist/common/htmltable-parse-font.d.ts.map +1 -0
  18. package/dist/common/htmltable-parse-state.d.ts +39 -0
  19. package/dist/common/htmltable-parse-state.d.ts.map +1 -0
  20. package/dist/common/htmltable-parse.d.ts +18 -23
  21. package/dist/common/htmltable-parse.d.ts.map +1 -1
  22. package/dist/common/htmltable-pos.d.ts.map +1 -1
  23. package/dist/common/htmltable-types.d.ts +33 -3
  24. package/dist/common/htmltable-types.d.ts.map +1 -1
  25. package/dist/common/htmltable.d.ts +3 -2
  26. package/dist/common/htmltable.d.ts.map +1 -1
  27. package/dist/common/spline-midpoint.d.ts.map +1 -1
  28. package/dist/common/splines-geom.d.ts +5 -6
  29. package/dist/common/splines-geom.d.ts.map +1 -1
  30. package/dist/common/splines-routespl.d.ts.map +1 -1
  31. package/dist/common/taper.d.ts.map +1 -1
  32. package/dist/gvc/anchor.d.ts.map +1 -1
  33. package/dist/index.js +1536 -786
  34. package/dist/index.js.map +4 -4
  35. package/dist/layout/dot/compound-clip.d.ts +5 -5
  36. package/dist/layout/dot/compound-clip.d.ts.map +1 -1
  37. package/dist/layout/dot/compound-geom.d.ts +2 -7
  38. package/dist/layout/dot/compound-geom.d.ts.map +1 -1
  39. package/dist/layout/dot/compound.d.ts +1 -1
  40. package/dist/layout/dot/compound.d.ts.map +1 -1
  41. package/dist/layout/dot/edge-route-clip.d.ts +1 -1
  42. package/dist/layout/dot/edge-route-clip.d.ts.map +1 -1
  43. package/dist/layout/dot/ortho-adapter.d.ts +13 -4
  44. package/dist/layout/dot/ortho-adapter.d.ts.map +1 -1
  45. package/dist/layout/dot/splines-label.d.ts.map +1 -1
  46. package/dist/layout/neato/bfs.d.ts +10 -3
  47. package/dist/layout/neato/bfs.d.ts.map +1 -1
  48. package/dist/layout/neato/init.d.ts +6 -11
  49. package/dist/layout/neato/init.d.ts.map +1 -1
  50. package/dist/layout/neato/splines.d.ts.map +1 -1
  51. package/dist/parser/ast.d.ts +2 -0
  52. package/dist/parser/ast.d.ts.map +1 -1
  53. package/dist/parser/index.d.ts.map +1 -1
  54. package/dist/parser/scan-token.d.ts +40 -0
  55. package/dist/parser/scan-token.d.ts.map +1 -0
  56. package/dist/render/svg-edge-split.d.ts.map +1 -1
  57. package/dist/render.js +1558 -808
  58. package/dist/render.js.map +4 -4
  59. package/package.json +1 -1
  60. package/src/common/arrows-shapes-poly.ts +9 -10
  61. package/src/common/bezier.ts +33 -0
  62. package/src/common/htmltable-attr-warn.ts +270 -0
  63. package/src/common/htmltable-fontinfo.ts +83 -0
  64. package/src/common/htmltable-lex-expat.ts +357 -0
  65. package/src/common/htmltable-lex-units.ts +75 -0
  66. package/src/common/htmltable-lex.ts +221 -160
  67. package/src/common/htmltable-parse-attrs.ts +10 -14
  68. package/src/common/htmltable-parse-font.ts +92 -0
  69. package/src/common/htmltable-parse-state.ts +64 -0
  70. package/src/common/htmltable-parse.ts +138 -98
  71. package/src/common/htmltable-pos.ts +9 -8
  72. package/src/common/htmltable-types.ts +35 -3
  73. package/src/common/htmltable.ts +14 -7
  74. package/src/common/spline-midpoint.ts +2 -22
  75. package/src/common/splines-geom.ts +5 -25
  76. package/src/common/splines-routespl.ts +21 -13
  77. package/src/common/taper.ts +2 -14
  78. package/src/gvc/anchor.ts +5 -14
  79. package/src/layout/dot/compound-clip.ts +9 -19
  80. package/src/layout/dot/compound-geom.ts +6 -23
  81. package/src/layout/dot/compound.ts +1 -1
  82. package/src/layout/dot/edge-route-clip.ts +6 -29
  83. package/src/layout/dot/ortho-adapter.ts +5 -5
  84. package/src/layout/dot/splines-label.ts +3 -29
  85. package/src/layout/neato/bfs.ts +13 -4
  86. package/src/layout/neato/init.ts +15 -15
  87. package/src/layout/neato/splines.ts +4 -8
  88. package/src/parser/ast.ts +2 -0
  89. package/src/parser/dot.js +168 -137
  90. package/src/parser/dot.pegjs +13 -3
  91. package/src/parser/index.ts +17 -2
  92. package/src/parser/scan-token.ts +289 -0
  93. package/src/render/svg-edge-split.ts +5 -13
@@ -12,13 +12,17 @@
12
12
  */
13
13
 
14
14
  import { HtmlParseError } from './htmltable-types.js';
15
- import type { Token } from './htmltable-types.js';
16
- import { HTML_ENTITIES, htmlEntityUTF8 } from './html-entities.js';
15
+ import type { LexerOutput, OpenToken, TextToken, Token } from './htmltable-types.js';
16
+ import { htmlEntityUTF8 } from './html-entities.js';
17
+ import { checkAttrs } from './htmltable-attr-warn.js';
18
+ import type { AttrEntry } from './htmltable-attr-warn.js';
19
+ import { ExpatFeed, fedBytes, findUndefinedEntity, parseTagChunk } from './htmltable-lex-expat.js';
20
+ import type { ExpatResult, ExpatToken } from './htmltable-lex-expat.js';
21
+ import { findNext } from './htmltable-lex-units.js';
22
+ import type { Unit } from './htmltable-lex-units.js';
17
23
 
18
24
  export type { Token } from './htmltable-types.js';
19
25
 
20
- type Attrs = { [k: string]: string };
21
-
22
26
  const KNOWN_TAGS = new Set([
23
27
  'TABLE', 'TR', 'TD', 'TH', 'FONT', 'BR', 'IMG',
24
28
  'B', 'I', 'U', 'O', 'S', 'SUB', 'SUP', 'HR', 'VR', 'HTML',
@@ -40,17 +44,19 @@ const isWs = (c: string | undefined): boolean =>
40
44
  * phantom attributes). Bare or unquoted attributes are not well-formed
41
45
  * XML — expat rejects the document and the label falls back to plain
42
46
  * text; throwing here reproduces that.
47
+ * Names keep their source case (doAttrs prints them as written) and pairs
48
+ * keep document order.
43
49
  * @see lib/common/htmllex.c (XML_Parse "not well-formed" error path)
44
50
  */
45
- function parseAttrs(raw: string) {
46
- const out: Attrs = {};
51
+ function parseAttrPairs(raw: string): AttrEntry[] {
52
+ const out: AttrEntry[] = [];
47
53
  let i = 0;
48
54
  while (i < raw.length) {
49
55
  while (isWs(raw[i])) i++;
50
56
  if (i >= raw.length) break;
51
57
  const m = /^[\w-]+/.exec(raw.slice(i));
52
58
  if (m === null) throw new HtmlParseError(raw.slice(i, i + 10));
53
- const name = m[0].toLowerCase();
59
+ const name = m[0];
54
60
  i += m[0].length;
55
61
  while (isWs(raw[i])) i++;
56
62
  if (raw[i] !== '=') throw new HtmlParseError(name);
@@ -60,7 +66,7 @@ function parseAttrs(raw: string) {
60
66
  if (q !== '"' && q !== "'") throw new HtmlParseError(name);
61
67
  const end = raw.indexOf(q, i + 1);
62
68
  if (end < 0) throw new HtmlParseError(name);
63
- out[name] = raw.slice(i + 1, end);
69
+ out.push([name, raw.slice(i + 1, end)]);
64
70
  i = end + 1;
65
71
  }
66
72
  return out;
@@ -93,68 +99,64 @@ function cleanText(raw: string) {
93
99
  return raw.replace(/[\x00-\x1f\x7f]/g, (c) => isKeep(c) ? c : '');
94
100
  }
95
101
 
96
- /** Scan text run up to next tag; push non-empty token; return new index. */
97
- function scanText(src: string, start: number, tokens: Token[]) {
98
- let j = start;
99
- while (j < src.length && src[j] !== '<') j++;
102
+ /** The text token of a text run, if anything is left after cleaning. */
103
+ function textTokens(raw: string): Token[] {
100
104
  // Decode XML/HTML entities in the text run. C's HTML lexer resolves entities
101
105
  // via expat during tokenization (htmllex.c), so both sizing and emit see the
102
106
  // decoded UTF-8 text. Skipping this measured the raw "&#91;el..." (9-10 chars)
103
107
  // instead of "[el..." (6 chars), inflating edge-label vnodes and shifting
104
108
  // layout (corpus 1949: +18.7px). Plain-string labels already decode via the
105
109
  // make_label path, so only HTML-delimited labels diverged.
106
- const clean = htmlEntityUTF8(cleanText(src.slice(start, j)));
107
- if (clean.length > 0) tokens.push({ type: 'text', value: clean });
108
- return j;
109
- }
110
-
111
- /** Scan one tag; push token; return index after closing bracket. */
112
- function scanTag(src: string, start: number, tokens: Token[]) {
113
- let j = start + 1;
114
- while (j < src.length && src[j] !== '>') j++;
115
- const inner = src.slice(start + 1, j).trim();
116
- const next = j + 1;
117
- if (inner.startsWith('!--')) return next;
118
- if (inner.startsWith('/')) { tokens.push(parseCloseToken(inner)); return next; }
110
+ const clean = htmlEntityUTF8(cleanText(raw));
111
+ return clean.length > 0 ? [{ type: 'text', value: clean }] : [];
112
+ }
113
+
114
+ /** The tokens of one complete `<..>` tag. */
115
+ function tagTokens(text: string): Token[] {
116
+ const inner = text.slice(1, -1).trim();
117
+ if (inner.startsWith('/')) return [parseCloseToken(inner)];
119
118
  // Self-closing <X/>: expat reports startElement then endElement.
120
119
  // @see lib/common/htmllex.c (XML_SetElementHandler callbacks)
121
120
  if (inner.endsWith('/')) {
122
121
  const open = parseOpenToken(inner.slice(0, -1).trim());
123
- tokens.push(open, { type: 'close', tag: open.tag });
124
- return next;
122
+ return [open, { type: 'close', tag: open.tag }];
125
123
  }
126
- tokens.push(parseOpenToken(inner));
127
- return next;
124
+ return [parseOpenToken(inner)];
125
+ }
126
+
127
+ /**
128
+ * The open token for `tag` and its raw attribute text: `doAttrs` runs the
129
+ * attributes through the element's table, and the stderr it would write rides
130
+ * on the token for the parser to print.
131
+ * @see lib/common/htmllex.c:doAttrs
132
+ */
133
+ function openToken(tag: string, raw: string): OpenToken {
134
+ const { attrs, warnings, warn } = checkAttrs(tag, parseAttrPairs(raw));
135
+ const tok: OpenToken = { type: 'open', tag, attrs };
136
+ if (warnings.length > 0) tok.warnings = warnings;
137
+ if (warn) tok.warn = true;
138
+ return tok;
128
139
  }
129
140
 
130
141
  /** Parse open tag — placed last so Lizard length-to-EOF stays within 30. */
131
142
  function parseOpenToken(inner: string) {
132
143
  const sp = inner.search(/\s/);
133
- if (sp === -1) {
134
- const tag = inner.toUpperCase();
135
- checkTag(tag);
136
- return { type: 'open' as const, tag: normalizeTag(tag), attrs: parseAttrs('') };
137
- }
138
- const tag = inner.slice(0, sp).toUpperCase();
144
+ const tag = (sp === -1 ? inner : inner.slice(0, sp)).toUpperCase();
139
145
  checkTag(tag);
140
- return { type: 'open' as const, tag: normalizeTag(tag), attrs: parseAttrs(inner.slice(sp + 1)) };
146
+ return openToken(normalizeTag(tag), sp === -1 ? '' : inner.slice(sp + 1));
141
147
  }
142
148
 
143
149
  /**
144
- * expat `XML_ErrorString` texts for the two errors the port detects. Taken
145
- * from native dot's stderr (graphviz 16.1.0): expat's xmlparse.c is not
146
- * vendored with the graphviz sources.
150
+ * expat `XML_ErrorString` texts for the errors the port detects. Taken from
151
+ * native dot's stderr (graphviz 16.1.0): expat's xmlparse.c is not vendored
152
+ * with the graphviz sources.
147
153
  * @see lib/common/htmllex.c:htmllex (XML_ErrorString at the XML_Parse failure)
148
154
  */
149
155
  const XML_ERROR_UNDEFINED_ENTITY = 'undefined entity';
150
156
  const XML_ERROR_TAG_MISMATCH = 'mismatched tag';
151
- const XML_ERROR_INVALID_TOKEN = 'not well-formed (invalid token)';
152
157
  const XML_ERROR_DUPLICATE_ATTRIBUTE = 'duplicate attribute';
153
-
154
- /** Entities expat knows without a DTD; `scanEntity` leaves them as written. */
155
- const XML_PREDEFINED_ENTITIES = new Set(['lt', 'gt', 'amp', 'quot', 'apos']);
156
-
157
- const XML_NAME = /^[A-Za-z_:][\w.:-]*$/;
158
+ /** yacc's message when the token stream ends inside an unfinished construct. */
159
+ const SYNTAX_ERROR = 'syntax error';
158
160
 
159
161
  /**
160
162
  * A label failure C reports on stderr before dropping the HTML label
@@ -236,171 +238,230 @@ export function htmlErrorAtToken(message: string, tok: Token | undefined): HtmlL
236
238
  return htmlErrorInLine(message, chunkLine(info), info.context);
237
239
  }
238
240
 
239
- /** Extent of the next chunk: `<..>` or the characters up to the next `<`. */
240
- function chunkEnd(src: string, i: number): number {
241
- const j = src[i] === '<' ? src.indexOf('>', i + 1) : src.indexOf('<', i);
242
- if (j < 0) return src.length;
243
- return src[i] === '<' ? j + 1 : j;
241
+ /** Where an expat token sits in the label and the units C had lexed when it ran. */
242
+ interface TokenSite {
243
+ src: string;
244
+ tok: ExpatToken;
245
+ st: ChunkState;
244
246
  }
245
247
 
246
- /** Offset of the first `&name;` that scanEntity leaves for expat to reject. */
247
- function findUndefinedEntity(text: string): number {
248
- for (let t = text.indexOf('&'); t >= 0; t = text.indexOf('&', t + 1)) {
249
- if (text[t + 1] === '#') continue;
250
- const semi = text.indexOf(';', t + 1);
251
- if (semi < 0) continue;
252
- const key = text.slice(t + 1, semi);
253
- if (XML_NAME.test(key) && !Object.hasOwn(HTML_ENTITIES, key) && !XML_PREDEFINED_ENTITIES.has(key)) {
254
- return t;
248
+ /**
249
+ * Raise the error C hits for this tag: expat's duplicate attribute and
250
+ * mismatched tag first (detected before the end handler runs), then
251
+ * `lexerror`'s unknown element.
252
+ * @see lib/common/htmllex.c:lexerror
253
+ */
254
+ function checkTagToken(site: TokenSite): void {
255
+ const { src, tok, st } = site;
256
+ const tag = parseTagChunk(src.slice(tok.start, tok.end));
257
+ if (tag === undefined) return;
258
+ const context = st.prev + st.curr;
259
+ if (tag.close) {
260
+ if (st.stack.length > 0 && st.stack.pop() !== tag.name) {
261
+ throw htmlErrorInLine(XML_ERROR_TAG_MISMATCH, lineAt(src, tok.start), context);
255
262
  }
263
+ } else {
264
+ if (tok.dup !== undefined) throw htmlErrorInLine(XML_ERROR_DUPLICATE_ATTRIBUTE, lineAt(src, tok.dup), context);
265
+ if (!tag.selfClose) st.stack.push(tag.name);
266
+ }
267
+ if (KNOWN_TAGS.has(tag.name.toUpperCase())) return;
268
+ const text = `Unknown HTML element <${tag.name}> on line ${lineAt(src, tok.start)} `;
269
+ throw new HtmlLexError(tag.name.toUpperCase(), text, [`Error: ${text}`]);
270
+ }
271
+
272
+ /** The tokens one expat event gives the parser, after the errors C would hit. */
273
+ function eventTokens(site: TokenSite): Token[] {
274
+ const { src, tok, st } = site;
275
+ const text = src.slice(tok.start, tok.end);
276
+ if (tok.kind === 'comment' || tok.kind === 'pi') return [];
277
+ if (tok.kind === 'tag') {
278
+ checkTagToken(site);
279
+ return tagTokens(text);
280
+ }
281
+ const off = findUndefinedEntity(text);
282
+ if (off >= 0) {
283
+ throw htmlErrorInLine(XML_ERROR_UNDEFINED_ENTITY, lineAt(src, tok.start + off), st.prev + st.curr);
256
284
  }
257
- return -1;
285
+ return textTokens(text);
258
286
  }
259
287
 
260
- interface TagChunk {
261
- close: boolean;
262
- selfClose: boolean;
263
- name: string;
288
+ /** What the lexer wrote since it last returned a token. */
289
+ interface Carry {
290
+ warnings: string[];
291
+ warn: boolean;
264
292
  }
265
293
 
266
- /** Element name of a complete `<..>` chunk; undefined for comments etc. */
267
- function parseTagChunk(chunk: string): TagChunk | undefined {
268
- if (!chunk.endsWith('>')) return undefined;
269
- const body = chunk.slice(1, -1);
270
- if (body.startsWith('!')) return undefined;
271
- const close = body.startsWith('/');
272
- const name = /^[^\s/>]*/.exec(close ? body.slice(1) : body)?.[0] ?? '';
273
- return { close, selfClose: !close && body.endsWith('/'), name };
294
+ /** A label's lexing so far (the state `htmllex` keeps between calls). */
295
+ interface LexRun {
296
+ src: string;
297
+ tokens: Token[];
298
+ st: ChunkState;
299
+ expat: ExpatFeed;
300
+ carry: Carry;
274
301
  }
275
302
 
276
- interface AttrError {
277
- message: string;
278
- offset: number;
303
+ /** Move what `carry` holds onto `first`, the token the parser is about to get. */
304
+ function attachCarry(carry: Carry, first: Token): void {
305
+ if (carry.warnings.length > 0) first.warnings = [...carry.warnings, ...(first.warnings ?? [])];
306
+ if (carry.warn) first.warn = true;
307
+ carry.warnings = [];
308
+ carry.warn = false;
279
309
  }
280
310
 
281
- const ATTR_NAME = /^[A-Za-z_:][\w.:-]*/;
282
- const isSpace = (c: string | undefined): boolean => c !== undefined && /\s/.test(c);
311
+ /** Fold the output of a token the parser will never see into `carry`. */
312
+ function absorb(carry: Carry, lost: Token[]): void {
313
+ for (const t of lost) {
314
+ carry.warnings.push(...(t.warnings ?? []));
315
+ if (t.warn === true) carry.warn = true;
316
+ }
317
+ }
283
318
 
284
- const invalidToken = (offset: number): AttrError => ({ message: XML_ERROR_INVALID_TOKEN, offset });
319
+ /** One event's tokens and where it came from. */
320
+ interface EventGroup {
321
+ tokens: Token[];
322
+ tok: ExpatToken;
323
+ }
285
324
 
286
- function skipSpace(chunk: string, i: number): number {
287
- let j = i;
288
- while (isSpace(chunk[j])) j++;
289
- return j;
325
+ /** The events of a call that gave the parser something, in order; the first error aborts. */
326
+ function eventGroups(run: LexRun, res: ExpatResult): EventGroup[] {
327
+ const groups: EventGroup[] = [];
328
+ for (const tok of res.tokens) {
329
+ const tokens = eventTokens({ src: run.src, tok, st: run.st });
330
+ if (tokens.length > 0) groups.push({ tokens, tok });
331
+ }
332
+ if (res.invalid !== undefined) {
333
+ const { message, offset } = res.invalid;
334
+ throw htmlErrorInLine(message, lineAt(run.src, offset), run.st.prev + run.st.curr);
335
+ }
336
+ return groups;
290
337
  }
291
338
 
292
- interface AttrPair {
293
- name: string;
294
- next: number;
339
+ /**
340
+ * Hand the tokens of the event that won to the parser, joining a run of
341
+ * strings into one.
342
+ * @see lib/common/htmlparse.y:string
343
+ */
344
+ function deliver(run: LexRun, last: EventGroup): void {
345
+ const first = last.tokens[0]!;
346
+ attachCarry(run.carry, first);
347
+ const info: ChunkInfo = { src: run.src, pos: last.tok.start, end: last.tok.end, context: run.st.prev + run.st.curr };
348
+ const prev = run.tokens.at(-1);
349
+ if (prev?.type === 'text' && first.type === 'text') {
350
+ appendString(prev, first);
351
+ chunkInfo.set(prev, { ...info, pos: chunkInfo.get(prev)?.pos ?? info.pos });
352
+ return;
353
+ }
354
+ for (const t of last.tokens) chunkInfo.set(t, info);
355
+ run.tokens.push(...last.tokens);
295
356
  }
296
357
 
297
358
  /**
298
- * The `name = "value"` pair at `i`, or the error expat raises there;
299
- * undefined for an unterminated quote, which is not modelled.
359
+ * Run the events of one `XML_Parse` call. Each handler assigns `ctx->tok`, so
360
+ * only the last event's tokens reach the parser; what the others wrote still
361
+ * went to stderr. Then the call's error, if any.
362
+ * @see lib/common/htmllex.c:htmllex
300
363
  */
301
- function readAttrPair(chunk: string, i: number): AttrPair | AttrError | undefined {
302
- const name = ATTR_NAME.exec(chunk.slice(i))?.[0];
303
- if (name === undefined) return invalidToken(i);
304
- let j = skipSpace(chunk, i + name.length);
305
- if (chunk[j] !== '=') return invalidToken(j);
306
- j = skipSpace(chunk, j + 1);
307
- const q = chunk[j];
308
- if (q !== '"' && q !== "'") return invalidToken(j);
309
- const end = chunk.indexOf(q, j + 1);
310
- return end < 0 ? undefined : { name, next: end + 1 };
364
+ function runEvents(run: LexRun, res: ExpatResult): void {
365
+ const groups = eventGroups(run, res);
366
+ const last = groups.pop();
367
+ for (const g of groups) absorb(run.carry, g.tokens);
368
+ if (last !== undefined) deliver(run, last);
311
369
  }
312
370
 
313
371
  /**
314
- * Walk the `name="value"` pairs of an open tag as expat does and return the
315
- * first well-formedness error with its offset in the chunk. A duplicate is
316
- * only reported when no invalid token follows it, as expat checks duplicates
317
- * after tokenizing the whole tag.
372
+ * `string : string T_string`. A run of T_string tokens (a comment between two
373
+ * strings yields no token) is one string: the characters accumulate in the
374
+ * shared buffer until the parser reduces them.
375
+ * @see lib/common/htmlparse.y:string
318
376
  */
319
- function findAttrError(chunk: string, from: number): AttrError | undefined {
320
- const seen = new Set<string>();
321
- let dup: AttrError | undefined;
322
- let i = skipSpace(chunk, from);
323
- while (chunk[i] !== '>' && !(chunk[i] === '/' && chunk[i + 1] === '>')) {
324
- const pair = readAttrPair(chunk, i);
325
- if (pair === undefined) return undefined;
326
- if ('message' in pair) return pair;
327
- if (seen.has(pair.name) && dup === undefined) dup = { message: XML_ERROR_DUPLICATE_ATTRIBUTE, offset: i };
328
- seen.add(pair.name);
329
- i = skipSpace(chunk, pair.next);
330
- }
331
- return dup;
377
+ function appendString(into: TextToken, more: TextToken): void {
378
+ into.value += more.value;
379
+ if (more.warnings !== undefined) into.warnings = [...(into.warnings ?? []), ...more.warnings];
380
+ if (more.warn === true) into.warn = true;
381
+ }
382
+
383
+ /** The label string as C holds it: `start` and `end` bound the part `findNext` scans. */
384
+ export interface LabelRange {
385
+ start: number;
386
+ end: number;
387
+ }
388
+
389
+ interface LexUnit extends Unit {
390
+ start: number;
332
391
  }
333
392
 
334
393
  /**
335
- * Raise the error C hits for this tag chunk: expat's mismatched tag first
336
- * (it is detected before the end handler runs), then `lexerror`'s unknown
337
- * element.
338
- * @see lib/common/htmllex.c:lexerror
394
+ * C's units: `<HTML>` first, the label cut by `findNext`, then `</HTML>`. A
395
+ * source without the wrapper has empty ends.
396
+ * @see lib/common/htmllex.c:htmllex
339
397
  */
340
- function checkTagChunk(src: string, start: number, st: ChunkState): void {
341
- const tag = parseTagChunk(st.curr);
342
- if (tag === undefined) return;
343
- if (tag.close) {
344
- if (st.stack.length > 0 && st.stack.pop() !== tag.name) {
345
- throw htmlErrorInLine(XML_ERROR_TAG_MISMATCH, lineAt(src, start), st.prev + st.curr);
346
- }
347
- } else {
348
- const bad = findAttrError(st.curr, 1 + tag.name.length);
349
- if (bad !== undefined) {
350
- throw htmlErrorInLine(bad.message, lineAt(src, start + bad.offset), st.prev + st.curr);
351
- }
352
- if (!tag.selfClose) st.stack.push(tag.name);
398
+ function* lexUnits(src: string, label: LabelRange): Generator<LexUnit> {
399
+ if (label.start > 0) yield { start: 0, end: label.start, warnings: [], warn: false };
400
+ const text = src.slice(label.start, label.end);
401
+ for (let at = 0; at < text.length;) {
402
+ const u = findNext(text, at);
403
+ yield { start: label.start + at, end: label.start + u.end, warnings: u.warnings, warn: u.warn };
404
+ at = u.end;
353
405
  }
354
- if (KNOWN_TAGS.has(tag.name.toUpperCase())) return;
355
- const text = `Unknown HTML element <${tag.name}> on line ${lineAt(src, start)} `;
356
- throw new HtmlLexError(tag.name.toUpperCase(), text, [`Error: ${text}`]);
406
+ if (label.end < src.length) yield { start: label.end, end: src.length, warnings: [], warn: false };
357
407
  }
358
408
 
359
- /** Raise the expat or lexer error C would hit while parsing this chunk. */
360
- function checkChunk(src: string, start: number, st: ChunkState): void {
361
- if (st.curr.startsWith('<')) {
362
- checkTagChunk(src, start, st);
363
- return;
364
- }
365
- const off = findUndefinedEntity(st.curr);
366
- if (off >= 0) {
367
- throw htmlErrorInLine(XML_ERROR_UNDEFINED_ENTITY, lineAt(src, start + off), st.prev + st.curr);
368
- }
409
+ /**
410
+ * The syntax error yacc reports when the stream ends while expat still holds
411
+ * an unfinished token (the held bytes swallowed the closing `</HTML>`).
412
+ * @see lib/common/htmllex.c:htmlerror
413
+ */
414
+ function unfinishedError(run: LexRun, held: number): HtmlParseError {
415
+ return htmlErrorInLine(SYNTAX_ERROR, lineAt(run.src, held), run.st.prev + run.st.curr);
369
416
  }
370
417
 
371
418
  /** Tokens produced before the first lexical error, plus that error. */
372
419
  export interface TokenizeResult {
373
420
  tokens: Token[];
374
421
  error?: HtmlParseError;
422
+ /** Output of the lexer after its last token: printed when the parser asks for more. */
423
+ eof?: LexerOutput;
375
424
  }
376
425
 
426
+ const eofOutput = (carry: Carry): LexerOutput | undefined => {
427
+ if (carry.warnings.length === 0 && !carry.warn) return undefined;
428
+ return carry.warn ? { warnings: carry.warnings, warn: true } : { warnings: carry.warnings };
429
+ };
430
+
377
431
  /**
378
432
  * Tokenize until the first lexical error and return both. C interleaves
379
433
  * lexing with the yacc parse, so a syntax error in the tokens before the
380
434
  * failing chunk is reported in preference to the lexical error; callers that
381
435
  * parse the partial stream get that precedence.
436
+ * @see lib/common/htmllex.c:htmllex
382
437
  * @see lib/common/htmllex.c:findNext
383
438
  */
384
- export function tokenizeDeferred(src: string): TokenizeResult {
385
- const tokens: Token[] = [];
386
- const st: ChunkState = { stack: [], prev: '', curr: '' };
387
- let i = 0;
439
+ export function tokenizeDeferred(src: string, label: LabelRange = { start: 0, end: src.length }): TokenizeResult {
440
+ const run: LexRun = {
441
+ src, tokens: [], st: { stack: [], prev: '', curr: '' }, expat: new ExpatFeed(src),
442
+ carry: { warnings: [], warn: false },
443
+ };
444
+ let error: HtmlParseError | undefined;
388
445
  try {
389
- while (i < src.length) {
390
- const first = tokens.length;
391
- const start = i;
392
- st.prev = st.curr;
393
- st.curr = src.slice(i, chunkEnd(src, i));
394
- checkChunk(src, i, st);
395
- i = src[i] === '<' ? scanTag(src, i, tokens) : scanText(src, i, tokens);
396
- const info: ChunkInfo = { src, pos: start, end: i, context: st.prev + st.curr };
397
- for (let k = first; k < tokens.length; k++) chunkInfo.set(tokens[k]!, info);
446
+ for (const u of lexUnits(src, label)) {
447
+ run.st.prev = run.st.curr;
448
+ run.st.curr = src.slice(u.start, u.end);
449
+ run.carry.warnings.push(...u.warnings);
450
+ if (u.warn) run.carry.warn = true;
451
+ const nbytes = fedBytes(run.st.curr, !run.st.curr.startsWith('<'));
452
+ runEvents(run, run.expat.feed(u.start, u.end, nbytes));
398
453
  }
454
+ const held = run.expat.held;
455
+ if (held !== undefined) error = unfinishedError(run, held);
399
456
  } catch (e) {
400
- if (e instanceof HtmlParseError) return { tokens, error: e };
401
- throw e;
457
+ if (!(e instanceof HtmlParseError)) throw e;
458
+ error = e;
402
459
  }
403
- return { tokens };
460
+ const result: TokenizeResult = { tokens: run.tokens };
461
+ if (error !== undefined) result.error = error;
462
+ const eof = eofOutput(run.carry);
463
+ if (eof !== undefined) result.eof = eof;
464
+ return result;
404
465
  }
405
466
 
406
467
  /** @see lib/common/htmllex.c:findNext */
@@ -66,7 +66,7 @@ export const parseTitle = (a: Record<string, string>): string | undefined =>
66
66
 
67
67
  /**
68
68
  * Parse SIDES attribute into a bitmask matching C sidesfn().
69
- * Unrecognised chars are silently skipped (C issues a warning but continues).
69
+ * Unrecognised chars are skipped (the lexer has already warned, as C does).
70
70
  * C guard: if (flags != BORDER_MASK) p->flags |= flags — the full-mask case
71
71
  * is a no-op (means "all sides", the default), so we return undefined for it.
72
72
  * @see lib/common/htmllex.c:sidesfn
@@ -86,31 +86,27 @@ export const parseSides = (v: string | undefined): number | undefined => {
86
86
  };
87
87
 
88
88
  /**
89
- * Parse GRADIENTANGLE attribute — integer in [0, 360].
90
- * Out-of-range or non-integer values are ignored (C issues a warning).
89
+ * Parse GRADIENTANGLE attribute — `strtol` prefix, as `num`. The lexer has
90
+ * already dropped values outside [0, 360] (doInt).
91
91
  * @see lib/common/htmllex.c:gradientanglefn
92
92
  */
93
- export const parseGradientAngle = (v: string | undefined): number | undefined => {
94
- if (v === undefined) return undefined;
95
- const n = parseInt(v, 10);
96
- if (isNaN(n) || String(n) !== v.trim()) return undefined;
97
- if (n < 0 || n > 360) return undefined;
98
- return n;
99
- };
93
+ export const parseGradientAngle = (v: string | undefined): number | undefined => num(v);
100
94
 
101
95
  /**
102
- * Parse COLUMNS attribute — only "*" is valid (sets vrule=true).
96
+ * Parse COLUMNS attribute — a value starting with "*" sets vrule=true
97
+ * (C tests `*v != '*'`; anything else was already ignored by the lexer).
103
98
  * @see lib/common/htmllex.c:columnsfn
104
99
  */
105
100
  export const parseColumns = (v: string | undefined): true | undefined =>
106
- v === '*' ? true : undefined;
101
+ v?.startsWith('*') === true ? true : undefined;
107
102
 
108
103
  /**
109
- * Parse ROWS attribute — only "*" is valid (sets hrule=true).
104
+ * Parse ROWS attribute — a value starting with "*" sets hrule=true
105
+ * (C tests `*v != '*'`; anything else was already ignored by the lexer).
110
106
  * @see lib/common/htmllex.c:rowsfn
111
107
  */
112
108
  export const parseRows = (v: string | undefined): true | undefined =>
113
- v === '*' ? true : undefined;
109
+ v?.startsWith('*') === true ? true : undefined;
114
110
 
115
111
  export const buildCell = (content: HtmlCellContent[], a: Record<string, string>): HtmlCell => ({
116
112
  kind: 'cell',
@@ -0,0 +1,92 @@
1
+ // SPDX-License-Identifier: EPL-2.0
2
+ /**
3
+ * Font stack of the HTML-like label parser: `pushFont` merging, the inline
4
+ * tags' fonts, and the font a `fonttable` wrapper gives its table.
5
+ *
6
+ * @see lib/common/htmlparse.y:pushFont
7
+ * @see lib/common/htmlparse.y:fonttable
8
+ */
9
+
10
+ import type { HtmlTableFont, HtmlTextItem, OpenToken } from './htmltable-types.js';
11
+ import { num } from './htmltable-parse-attrs.js';
12
+
13
+ export interface FontState {
14
+ bold?: boolean;
15
+ italic?: boolean;
16
+ underline?: boolean;
17
+ overline?: boolean;
18
+ strikethrough?: boolean;
19
+ subscript?: boolean;
20
+ superscript?: boolean;
21
+ fontColor?: string;
22
+ fontFace?: string;
23
+ fontSize?: number;
24
+ }
25
+
26
+ const definedOf = (s: FontState): Partial<FontState> =>
27
+ Object.fromEntries(
28
+ Object.entries(s).filter(([, v]) => v !== undefined),
29
+ ) as Partial<FontState>;
30
+
31
+ export const mergeFontState = (parent: FontState, child: FontState): FontState =>
32
+ ({ ...parent, ...definedOf(child) }) as FontState;
33
+
34
+ const BOOL_FONT_KEYS = [
35
+ 'bold', 'italic', 'underline', 'overline',
36
+ 'strikethrough', 'subscript', 'superscript',
37
+ ] as const;
38
+ type BoolFontKey = typeof BOOL_FONT_KEYS[number];
39
+
40
+ export const fontItemProps = (f: FontState): Partial<HtmlTextItem> => {
41
+ const out: Partial<HtmlTextItem> = {};
42
+ for (const k of BOOL_FONT_KEYS) {
43
+ if (f[k]) out[k as BoolFontKey] = true;
44
+ }
45
+ if (f.fontColor) out.fontColor = f.fontColor;
46
+ if (f.fontFace) out.fontFace = f.fontFace;
47
+ if (f.fontSize !== undefined) out.fontSize = f.fontSize;
48
+ return out;
49
+ };
50
+
51
+
52
+ export const isInlineTag = (tag: string): boolean =>
53
+ ['B', 'I', 'U', 'O', 'S', 'SUB', 'SUP'].includes(tag);
54
+
55
+ export const inlineFontState = (tag: string, _attrs: Record<string, string>): FontState => {
56
+ const fs: FontState = {};
57
+ if (tag === 'B') fs.bold = true;
58
+ if (tag === 'I') fs.italic = true;
59
+ if (tag === 'U') fs.underline = true;
60
+ if (tag === 'O') fs.overline = true;
61
+ if (tag === 'S') fs.strikethrough = true;
62
+ if (tag === 'SUB') fs.subscript = true;
63
+ if (tag === 'SUP') fs.superscript = true;
64
+ return fs;
65
+ };
66
+
67
+ /** The font `pushFont` pushes for a wrapper tag, merged over the enclosing one. */
68
+ export const wrapperFont = (t: OpenToken, fontStack: FontState[]): FontState => {
69
+ const font = fontStack[fontStack.length - 1] ?? {};
70
+ if (t.tag !== 'FONT') return mergeFontState(font, inlineFontState(t.tag, t.attrs));
71
+ return mergeFontState(font, {
72
+ fontColor: t.attrs['color'],
73
+ fontFace: t.attrs['face'],
74
+ fontSize: num(t.attrs['point-size']),
75
+ });
76
+ };
77
+
78
+ /**
79
+ * `$2->font = *LIST_BACK(fontstack)`: set only while a `fonttable` wrapper's
80
+ * font is on the stack (the base entry stands for C's NULL).
81
+ * @see lib/common/htmlparse.y:294
82
+ */
83
+ export const tableFont = (fontStack: FontState[]): HtmlTableFont | undefined => {
84
+ if (fontStack.length < 2) return undefined;
85
+ const { fontColor, fontFace, fontSize } = fontStack[fontStack.length - 1]!;
86
+ const font: HtmlTableFont = {};
87
+ if (fontColor !== undefined) font.fontColor = fontColor;
88
+ if (fontFace !== undefined) font.fontFace = fontFace;
89
+ if (fontSize !== undefined) font.fontSize = fontSize;
90
+ return font;
91
+ };
92
+