pretext-pdf 0.5.3 → 0.8.1

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 (57) hide show
  1. package/CHANGELOG.md +115 -0
  2. package/README.md +366 -276
  3. package/dist/assets.d.ts +5 -0
  4. package/dist/assets.d.ts.map +1 -1
  5. package/dist/assets.js +248 -43
  6. package/dist/assets.js.map +1 -1
  7. package/dist/errors.d.ts +1 -1
  8. package/dist/errors.d.ts.map +1 -1
  9. package/dist/errors.js.map +1 -1
  10. package/dist/fonts.d.ts.map +1 -1
  11. package/dist/fonts.js +88 -16
  12. package/dist/fonts.js.map +1 -1
  13. package/dist/index.d.ts +29 -2
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +35 -2
  16. package/dist/index.js.map +1 -1
  17. package/dist/markdown.d.ts +28 -0
  18. package/dist/markdown.d.ts.map +1 -0
  19. package/dist/markdown.js +222 -0
  20. package/dist/markdown.js.map +1 -0
  21. package/dist/measure-blocks.d.ts.map +1 -1
  22. package/dist/measure-blocks.js +347 -62
  23. package/dist/measure-blocks.js.map +1 -1
  24. package/dist/measure-text.d.ts.map +1 -1
  25. package/dist/measure-text.js +1 -8
  26. package/dist/measure-text.js.map +1 -1
  27. package/dist/measure.d.ts.map +1 -1
  28. package/dist/measure.js +13 -21
  29. package/dist/measure.js.map +1 -1
  30. package/dist/render-blocks.d.ts +4 -1
  31. package/dist/render-blocks.d.ts.map +1 -1
  32. package/dist/render-blocks.js +227 -105
  33. package/dist/render-blocks.js.map +1 -1
  34. package/dist/render-extras.d.ts.map +1 -1
  35. package/dist/render-extras.js +72 -71
  36. package/dist/render-extras.js.map +1 -1
  37. package/dist/render-utils.d.ts +9 -2
  38. package/dist/render-utils.d.ts.map +1 -1
  39. package/dist/render-utils.js +24 -13
  40. package/dist/render-utils.js.map +1 -1
  41. package/dist/render.d.ts.map +1 -1
  42. package/dist/render.js +27 -3
  43. package/dist/render.js.map +1 -1
  44. package/dist/rich-text.d.ts +0 -4
  45. package/dist/rich-text.d.ts.map +1 -1
  46. package/dist/rich-text.js +15 -9
  47. package/dist/rich-text.js.map +1 -1
  48. package/dist/templates.d.ts +79 -0
  49. package/dist/templates.d.ts.map +1 -0
  50. package/dist/templates.js +201 -0
  51. package/dist/templates.js.map +1 -0
  52. package/dist/types.d.ts +139 -5
  53. package/dist/types.d.ts.map +1 -1
  54. package/dist/validate.d.ts.map +1 -1
  55. package/dist/validate.js +241 -28
  56. package/dist/validate.js.map +1 -1
  57. package/package.json +57 -12
package/dist/validate.js CHANGED
@@ -35,6 +35,25 @@ import { resolvePageDimensions } from './page-sizes.js';
35
35
  const RTL_REGEX = /[\u0590-\u08FF\uFB1D-\uFB4F\uFB50-\uFDFF\uFE70-\uFEFF\u{10800}-\u{10CFF}\u{10D00}-\u{10D3F}\u{10E80}-\u{10EFF}\u{10F30}-\u{10FFF}\u{1E800}-\u{1E95F}\u{1EC70}-\u{1ECBF}\u{1EE00}-\u{1EEFF}]/u;
36
36
  /** Valid 6-digit hex color */
37
37
  const HEX_COLOR_REGEX = /^#[0-9A-Fa-f]{6}$/;
38
+ /** Allowed URL schemes for hyperlinks — blocks javascript:, data:, vbscript: */
39
+ const SAFE_URL_SCHEME = /^(https?|mailto|ftp|#)/i;
40
+ /** BCP47 language tag pattern for hyphenation.language — prevents dynamic-import path injection */
41
+ const LANGUAGE_TAG_REGEX = /^[a-zA-Z]{2,8}(-[a-zA-Z0-9]{2,8})*$/;
42
+ /** Validate a hyperlink URL — throws VALIDATION_ERROR for unsafe schemes */
43
+ function validateUrl(url, prefix) {
44
+ if (!SAFE_URL_SCHEME.test(url)) {
45
+ throw new PretextPdfError('VALIDATION_ERROR', `${prefix}: URL scheme not allowed — only http, https, mailto, ftp, and anchor (#) links are permitted. Got: "${url.slice(0, 60)}"`);
46
+ }
47
+ }
48
+ /** Validate a metadata string field — rejects control chars and enforces length */
49
+ function validateMetadataString(value, fieldName) {
50
+ if (value.length > 1000) {
51
+ throw new PretextPdfError('VALIDATION_ERROR', `metadata.${fieldName} must not exceed 1000 characters`);
52
+ }
53
+ if (/[\x00\r\n]/.test(value)) {
54
+ throw new PretextPdfError('VALIDATION_ERROR', `metadata.${fieldName} must not contain null bytes or newline characters`);
55
+ }
56
+ }
38
57
  /** Valid column width: positive number OR '2*', '*', '1.5*' format */
39
58
  const STAR_WIDTH_REGEX = /^(\d*\.?\d+)?\*$/;
40
59
  /** Families always available without explicit doc.fonts entry */
@@ -183,8 +202,8 @@ export function validate(doc) {
183
202
  if (wm.opacity !== undefined && (typeof wm.opacity !== 'number' || wm.opacity < 0 || wm.opacity > 1 || !isFinite(wm.opacity))) {
184
203
  throw new PretextPdfError('VALIDATION_ERROR', 'doc.watermark.opacity must be a number 0.0–1.0');
185
204
  }
186
- if (wm.fontSize !== undefined && (typeof wm.fontSize !== 'number' || wm.fontSize <= 0 || !isFinite(wm.fontSize))) {
187
- throw new PretextPdfError('VALIDATION_ERROR', 'doc.watermark.fontSize must be a positive finite number');
205
+ if (wm.fontSize !== undefined && (typeof wm.fontSize !== 'number' || wm.fontSize <= 0 || wm.fontSize > 500 || !isFinite(wm.fontSize))) {
206
+ throw new PretextPdfError('VALIDATION_ERROR', 'doc.watermark.fontSize must be a positive finite number and <= 500');
188
207
  }
189
208
  if (wm.fontWeight !== undefined && ![400, 700].includes(wm.fontWeight)) {
190
209
  throw new PretextPdfError('VALIDATION_ERROR', "doc.watermark.fontWeight must be 400 or 700");
@@ -207,6 +226,9 @@ export function validate(doc) {
207
226
  if (enc.userPassword !== undefined && typeof enc.userPassword !== 'string') {
208
227
  throw new PretextPdfError('VALIDATION_ERROR', 'doc.encryption.userPassword must be a string if provided');
209
228
  }
229
+ if (enc.userPassword !== undefined && enc.userPassword === '') {
230
+ throw new PretextPdfError('VALIDATION_ERROR', 'doc.encryption.userPassword must not be an empty string — an empty password provides no access control. Omit userPassword for permissions-only encryption.');
231
+ }
210
232
  if (enc.ownerPassword !== undefined && typeof enc.ownerPassword !== 'string') {
211
233
  throw new PretextPdfError('VALIDATION_ERROR', 'doc.encryption.ownerPassword must be a string if provided');
212
234
  }
@@ -273,6 +295,9 @@ export function validate(doc) {
273
295
  if (!h.language || typeof h.language !== 'string') {
274
296
  throw new PretextPdfError('VALIDATION_ERROR', 'doc.hyphenation.language is required (e.g. "en-us")');
275
297
  }
298
+ if (!LANGUAGE_TAG_REGEX.test(h.language)) {
299
+ throw new PretextPdfError('VALIDATION_ERROR', `doc.hyphenation.language must be a BCP47 tag like "en-us" or "de" (letters and hyphens only). Got: "${h.language}"`);
300
+ }
276
301
  if (h.minWordLength !== undefined && (h.minWordLength < 2 || h.minWordLength > 20)) {
277
302
  throw new PretextPdfError('VALIDATION_ERROR', 'doc.hyphenation.minWordLength must be 2–20');
278
303
  }
@@ -289,9 +314,17 @@ export function validate(doc) {
289
314
  if (m.language !== undefined && (typeof m.language !== 'string' || m.language.trim() === '')) {
290
315
  throw new PretextPdfError('VALIDATION_ERROR', 'metadata.language must be a non-empty string (BCP47 tag e.g. "en-US")');
291
316
  }
317
+ if (m.language !== undefined && typeof m.language === 'string')
318
+ validateMetadataString(m.language, 'language');
292
319
  if (m.producer !== undefined && (typeof m.producer !== 'string' || m.producer.trim() === '')) {
293
320
  throw new PretextPdfError('VALIDATION_ERROR', 'metadata.producer must be a non-empty string');
294
321
  }
322
+ // Validate free-text fields for injection chars and length
323
+ for (const field of ['title', 'author', 'subject', 'keywords', 'creator', 'producer']) {
324
+ const val = m[field];
325
+ if (val !== undefined && typeof val === 'string')
326
+ validateMetadataString(val, field);
327
+ }
295
328
  }
296
329
  // validate each content element
297
330
  const loadedFamilies = new Set([
@@ -362,6 +395,9 @@ function validateFontReferences(doc, loadedFamilies) {
362
395
  loadedVariants.add(`${f.family}-${f.weight ?? 400}-${f.style ?? 'normal'}`);
363
396
  }
364
397
  const requireFamily = (family, context) => {
398
+ if (!/^[a-zA-Z0-9 ._+\-]+$/.test(family)) {
399
+ throw new PretextPdfError('VALIDATION_ERROR', `${context}: font family name "${family}" contains invalid characters. Use only letters, digits, spaces, hyphens, and underscores.`);
400
+ }
365
401
  if (!loadedFamilies.has(family)) {
366
402
  throw new PretextPdfError('FONT_NOT_LOADED', `${context}: font family '${family}' is not loaded. Add { family: '${family}', src: '/path/to.ttf' } to doc.fonts, or remove the fontFamily reference to use the default ('${defaultFamily}').`);
367
403
  }
@@ -517,6 +553,8 @@ function validateElement(el, index, loadedFamilies) {
517
553
  if (el.url !== undefined && (typeof el.url !== 'string' || el.url.trim() === '')) {
518
554
  throw new PretextPdfError('VALIDATION_ERROR', `${prefix} (paragraph): 'url' must be a non-empty string if provided`);
519
555
  }
556
+ if (el.url !== undefined && typeof el.url === 'string')
557
+ validateUrl(el.url, `${prefix} (paragraph) url`);
520
558
  if (el.letterSpacing !== undefined && (typeof el.letterSpacing !== 'number' || el.letterSpacing < 0 || !isFinite(el.letterSpacing) || el.letterSpacing > 200)) {
521
559
  throw new PretextPdfError('VALIDATION_ERROR', `${prefix} (paragraph): 'letterSpacing' must be a non-negative finite number and <= 200`);
522
560
  }
@@ -524,6 +562,15 @@ function validateElement(el, index, loadedFamilies) {
524
562
  if (!el.annotation.contents || el.annotation.contents.trim() === '') {
525
563
  throw new PretextPdfError('VALIDATION_ERROR', `${prefix} (paragraph): annotation.contents is required and must be non-empty`);
526
564
  }
565
+ if (el.annotation.contents.length > 5000) {
566
+ throw new PretextPdfError('VALIDATION_ERROR', `${prefix} (paragraph): annotation.contents must be 5000 characters or fewer`);
567
+ }
568
+ if (el.annotation.color !== undefined && !/^#[0-9a-fA-F]{6}$/.test(el.annotation.color)) {
569
+ throw new PretextPdfError('VALIDATION_ERROR', `${prefix} (paragraph): annotation.color must be a 6-digit hex color (e.g. "#FFFF00"). Got: "${el.annotation.color}"`);
570
+ }
571
+ if (el.annotation.author !== undefined && el.annotation.author.length > 100) {
572
+ throw new PretextPdfError('VALIDATION_ERROR', `${prefix} (paragraph): annotation.author must be 100 characters or fewer`);
573
+ }
527
574
  }
528
575
  break;
529
576
  }
@@ -570,6 +617,8 @@ function validateElement(el, index, loadedFamilies) {
570
617
  if (el.url !== undefined && (typeof el.url !== 'string' || el.url.trim() === '')) {
571
618
  throw new PretextPdfError('VALIDATION_ERROR', `${prefix} (heading): 'url' must be a non-empty string if provided`);
572
619
  }
620
+ if (el.url !== undefined && typeof el.url === 'string')
621
+ validateUrl(el.url, `${prefix} (heading) url`);
573
622
  if (el.anchor !== undefined && (typeof el.anchor !== 'string' || !/^[a-zA-Z0-9_-]+$/.test(el.anchor))) {
574
623
  throw new PretextPdfError('VALIDATION_ERROR', `${prefix} (heading): 'anchor' must be alphanumeric with hyphens/underscores only. Got: '${el.anchor}'`);
575
624
  }
@@ -580,12 +629,21 @@ function validateElement(el, index, loadedFamilies) {
580
629
  if (!el.annotation.contents || el.annotation.contents.trim() === '') {
581
630
  throw new PretextPdfError('VALIDATION_ERROR', `${prefix} (heading): annotation.contents is required and must be non-empty`);
582
631
  }
632
+ if (el.annotation.contents.length > 5000) {
633
+ throw new PretextPdfError('VALIDATION_ERROR', `${prefix} (heading): annotation.contents must be 5000 characters or fewer`);
634
+ }
635
+ if (el.annotation.color !== undefined && !/^#[0-9a-fA-F]{6}$/.test(el.annotation.color)) {
636
+ throw new PretextPdfError('VALIDATION_ERROR', `${prefix} (heading): annotation.color must be a 6-digit hex color (e.g. "#FFFF00"). Got: "${el.annotation.color}"`);
637
+ }
638
+ if (el.annotation.author !== undefined && el.annotation.author.length > 100) {
639
+ throw new PretextPdfError('VALIDATION_ERROR', `${prefix} (heading): annotation.author must be 100 characters or fewer`);
640
+ }
583
641
  }
584
642
  break;
585
643
  }
586
644
  case 'spacer': {
587
- if (typeof el.height !== 'number' || el.height < 0 || !isFinite(el.height)) {
588
- throw new PretextPdfError('VALIDATION_ERROR', `${prefix} (spacer): 'height' must be a non-negative finite number`);
645
+ if (typeof el.height !== 'number' || el.height < 0 || el.height > 14400 || !isFinite(el.height)) {
646
+ throw new PretextPdfError('VALIDATION_ERROR', `${prefix} (spacer): 'height' must be a non-negative finite number and <= 14400pt (200 inches)`);
589
647
  }
590
648
  break;
591
649
  }
@@ -629,12 +687,36 @@ function validateElement(el, index, loadedFamilies) {
629
687
  if (headerRowCount >= el.rows.length) {
630
688
  throw new PretextPdfError('VALIDATION_ERROR', `${prefix} (table): table must have at least 1 non-header body row. headerRows=${headerRowCount}, total rows=${el.rows.length}`);
631
689
  }
690
+ // Build span occupancy grid for colspan validation (rowspan cells occupy future rows)
691
+ const spanOccupied = new Set();
692
+ for (let ri = 0; ri < el.rows.length; ri++) {
693
+ const row = el.rows[ri];
694
+ let ci = 0;
695
+ for (const cell of row.cells) {
696
+ while (spanOccupied.has(`${ri},${ci}`))
697
+ ci++;
698
+ const cs = cell.colspan ?? 1;
699
+ const rs = cell.rowspan ?? 1;
700
+ for (let r2 = ri + 1; r2 < ri + rs; r2++) {
701
+ for (let c2 = ci; c2 < ci + cs; c2++) {
702
+ spanOccupied.add(`${r2},${c2}`);
703
+ }
704
+ }
705
+ ci += cs;
706
+ }
707
+ }
632
708
  for (let ri = 0; ri < el.rows.length; ri++) {
633
709
  const row = el.rows[ri];
634
710
  if (!Array.isArray(row.cells)) {
635
711
  throw new PretextPdfError('VALIDATION_ERROR', `${prefix} (table): rows[${ri}].cells must be an array`);
636
712
  }
637
- // Validate colspan sum equals colCount
713
+ // Count how many columns in this row are occupied by rowspan cells from above
714
+ let occupiedCols = 0;
715
+ for (let ci = 0; ci < colCount; ci++) {
716
+ if (spanOccupied.has(`${ri},${ci}`))
717
+ occupiedCols++;
718
+ }
719
+ // Validate colspan sum equals colCount minus occupied columns
638
720
  let colspanSum = 0;
639
721
  for (let cellI = 0; cellI < row.cells.length; cellI++) {
640
722
  const cell = row.cells[cellI];
@@ -643,9 +725,13 @@ function validateElement(el, index, loadedFamilies) {
643
725
  throw new PretextPdfError('VALIDATION_ERROR', `${prefix} (table): rows[${ri}].cells[${cellI}].colspan must be a positive integer`);
644
726
  }
645
727
  colspanSum += cs;
728
+ const rs = cell.rowspan ?? 1;
729
+ if (typeof rs !== 'number' || rs < 1 || !Number.isInteger(rs)) {
730
+ throw new PretextPdfError('VALIDATION_ERROR', `${prefix} (table): rows[${ri}].cells[${cellI}].rowspan must be a positive integer`);
731
+ }
646
732
  }
647
- if (colspanSum !== colCount) {
648
- throw new PretextPdfError('COLSPAN_OVERFLOW', `${prefix} (table): rows[${ri}] colspan sum is ${colspanSum} but table has ${colCount} columns. Sum of all colspan values in a row must equal the column count.`);
733
+ if (colspanSum !== colCount - occupiedCols) {
734
+ throw new PretextPdfError('COLSPAN_OVERFLOW', `${prefix} (table): rows[${ri}] colspan sum is ${colspanSum} but expected ${colCount - occupiedCols} (${colCount} columns minus ${occupiedCols} occupied by rowspan). Sum of explicit cell colspans must cover only unoccupied columns.`);
649
735
  }
650
736
  for (let cellI = 0; cellI < row.cells.length; cellI++) {
651
737
  const cell = row.cells[cellI];
@@ -667,8 +753,17 @@ function validateElement(el, index, loadedFamilies) {
667
753
  if (cell.bgColor !== undefined && !HEX_COLOR_REGEX.test(cell.bgColor)) {
668
754
  throw new PretextPdfError('VALIDATION_ERROR', `${prefix} (table): rows[${ri}].cells[${cellI}].bgColor must be a 6-digit hex string`);
669
755
  }
756
+ if (cell.dir !== undefined && !['ltr', 'rtl', 'auto'].includes(cell.dir)) {
757
+ throw new PretextPdfError('VALIDATION_ERROR', `${prefix} (table): rows[${ri}].cells[${cellI}].dir must be 'ltr', 'rtl', or 'auto'`);
758
+ }
759
+ if (cell.align !== undefined && !['left', 'center', 'right'].includes(cell.align)) {
760
+ throw new PretextPdfError('VALIDATION_ERROR', `${prefix} (table): rows[${ri}].cells[${cellI}].align must be 'left', 'center', or 'right'`);
761
+ }
670
762
  }
671
763
  }
764
+ if (el.dir !== undefined && !['ltr', 'rtl', 'auto'].includes(el.dir)) {
765
+ throw new PretextPdfError('VALIDATION_ERROR', `${prefix} (table): 'dir' must be 'ltr', 'rtl', or 'auto'`);
766
+ }
672
767
  if (el.borderColor !== undefined && !HEX_COLOR_REGEX.test(el.borderColor)) {
673
768
  throw new PretextPdfError('VALIDATION_ERROR', `${prefix} (table): 'borderColor' must be a 6-digit hex string`);
674
769
  }
@@ -678,14 +773,14 @@ function validateElement(el, index, loadedFamilies) {
678
773
  if (el.fontSize !== undefined && (typeof el.fontSize !== 'number' || el.fontSize <= 0 || !isFinite(el.fontSize))) {
679
774
  throw new PretextPdfError('VALIDATION_ERROR', `${prefix} (table): 'fontSize' must be a positive finite number`);
680
775
  }
681
- if (el.borderWidth !== undefined && (typeof el.borderWidth !== 'number' || el.borderWidth < 0)) {
682
- throw new PretextPdfError('VALIDATION_ERROR', `${prefix} (table): 'borderWidth' must be a non-negative number`);
776
+ if (el.borderWidth !== undefined && (typeof el.borderWidth !== 'number' || el.borderWidth < 0 || el.borderWidth > 50)) {
777
+ throw new PretextPdfError('VALIDATION_ERROR', `${prefix} (table): 'borderWidth' must be a non-negative number <= 50`);
683
778
  }
684
- if (el.cellPaddingH !== undefined && (typeof el.cellPaddingH !== 'number' || el.cellPaddingH < 0)) {
685
- throw new PretextPdfError('VALIDATION_ERROR', `${prefix} (table): 'cellPaddingH' must be a non-negative number`);
779
+ if (el.cellPaddingH !== undefined && (typeof el.cellPaddingH !== 'number' || el.cellPaddingH < 0 || el.cellPaddingH > 200)) {
780
+ throw new PretextPdfError('VALIDATION_ERROR', `${prefix} (table): 'cellPaddingH' must be a non-negative number <= 200`);
686
781
  }
687
- if (el.cellPaddingV !== undefined && (typeof el.cellPaddingV !== 'number' || el.cellPaddingV < 0)) {
688
- throw new PretextPdfError('VALIDATION_ERROR', `${prefix} (table): 'cellPaddingV' must be a non-negative number`);
782
+ if (el.cellPaddingV !== undefined && (typeof el.cellPaddingV !== 'number' || el.cellPaddingV < 0 || el.cellPaddingV > 200)) {
783
+ throw new PretextPdfError('VALIDATION_ERROR', `${prefix} (table): 'cellPaddingV' must be a non-negative number <= 200`);
689
784
  }
690
785
  if (el.spaceAfter !== undefined && (typeof el.spaceAfter !== 'number' || el.spaceAfter < 0 || !isFinite(el.spaceAfter))) {
691
786
  throw new PretextPdfError('VALIDATION_ERROR', `${prefix} (table): 'spaceAfter' must be a non-negative finite number`);
@@ -699,6 +794,9 @@ function validateElement(el, index, loadedFamilies) {
699
794
  if (!el.src || (typeof el.src !== 'string' && !(el.src instanceof Uint8Array))) {
700
795
  throw new PretextPdfError('VALIDATION_ERROR', `${prefix} (image): 'src' must be a non-empty string path or Uint8Array`);
701
796
  }
797
+ if (typeof el.src === 'string' && (el.src.startsWith('\\\\') || el.src.startsWith('//'))) {
798
+ throw new PretextPdfError('VALIDATION_ERROR', `${prefix} (image): 'src' must not be a UNC/network path`);
799
+ }
702
800
  const fmt = el.format ?? 'auto';
703
801
  if (fmt !== 'png' && fmt !== 'jpg' && fmt !== 'auto') {
704
802
  throw new PretextPdfError('VALIDATION_ERROR', `${prefix} (image): 'format' must be 'png', 'jpg', or 'auto'. Got: '${String(el.format)}'`);
@@ -722,12 +820,18 @@ function validateElement(el, index, loadedFamilies) {
722
820
  if (el.float !== undefined && el.float !== 'left' && el.float !== 'right') {
723
821
  throw new PretextPdfError('VALIDATION_ERROR', `${prefix} (image): 'float' must be 'left' or 'right'`);
724
822
  }
725
- if (el.float !== undefined && (!el.floatText || el.floatText.trim() === '')) {
726
- throw new PretextPdfError('VALIDATION_ERROR', `${prefix} (image): 'floatText' is required when 'float' is set`);
823
+ if (el.float !== undefined && (!el.floatText || el.floatText.trim() === '') && (!el.floatSpans || el.floatSpans.length === 0)) {
824
+ throw new PretextPdfError('VALIDATION_ERROR', `${prefix} (image): 'floatText' or 'floatSpans' is required when 'float' is set`);
825
+ }
826
+ if (el.floatText !== undefined && el.floatSpans !== undefined) {
827
+ throw new PretextPdfError('VALIDATION_ERROR', `${prefix} (image): 'floatText' and 'floatSpans' are mutually exclusive — use one or the other`);
727
828
  }
728
829
  if (el.floatText !== undefined && el.float === undefined) {
729
830
  throw new PretextPdfError('VALIDATION_ERROR', `${prefix} (image): 'floatText' has no effect without 'float'`);
730
831
  }
832
+ if (el.floatSpans !== undefined && el.float === undefined) {
833
+ throw new PretextPdfError('VALIDATION_ERROR', `${prefix} (image): 'floatSpans' has no effect without 'float'`);
834
+ }
731
835
  if (el.floatWidth !== undefined && (typeof el.floatWidth !== 'number' || el.floatWidth <= 0 || !isFinite(el.floatWidth))) {
732
836
  throw new PretextPdfError('VALIDATION_ERROR', `${prefix} (image): 'floatWidth' must be a positive finite number`);
733
837
  }
@@ -748,8 +852,15 @@ function validateElement(el, index, loadedFamilies) {
748
852
  if (hasSvg && !el.svg.trim().startsWith('<')) {
749
853
  throw new PretextPdfError('SVG_INVALID_MARKUP', `${prefix} (svg): 'svg' must be valid SVG markup (must start with '<')`);
750
854
  }
751
- if (hasSrc && !el.src.startsWith('/') && !el.src.startsWith('https://') && !el.src.startsWith('http://') && !/^[A-Za-z]:[/\\]/.test(el.src)) {
752
- throw new PretextPdfError('VALIDATION_ERROR', `${prefix} (svg): 'src' must be an absolute file path or an https:// URL`);
855
+ if (hasSrc) {
856
+ const src = el.src;
857
+ const isUNC = src.startsWith('\\\\') || src.startsWith('//');
858
+ if (isUNC) {
859
+ throw new PretextPdfError('VALIDATION_ERROR', `${prefix} (svg): 'src' must not be a UNC/network path`);
860
+ }
861
+ if (!src.startsWith('/') && !src.startsWith('https://') && !/^[A-Za-z]:[/\\]/.test(src)) {
862
+ throw new PretextPdfError('VALIDATION_ERROR', `${prefix} (svg): 'src' must be an absolute file path or an https:// URL`);
863
+ }
753
864
  }
754
865
  if (el.width !== undefined && (typeof el.width !== 'number' || el.width <= 0 || !isFinite(el.width))) {
755
866
  throw new PretextPdfError('VALIDATION_ERROR', `${prefix} (svg): 'width' must be a positive finite number`);
@@ -768,6 +879,87 @@ function validateElement(el, index, loadedFamilies) {
768
879
  }
769
880
  break;
770
881
  }
882
+ case 'qr-code': {
883
+ if (typeof el.data !== 'string' || el.data.trim().length === 0) {
884
+ throw new PretextPdfError('VALIDATION_ERROR', `${prefix} (qr-code): 'data' must be a non-empty string`);
885
+ }
886
+ if (el.data.length > 2953) {
887
+ throw new PretextPdfError('VALIDATION_ERROR', `${prefix} (qr-code): 'data' exceeds maximum QR capacity of 2953 characters (got ${el.data.length})`);
888
+ }
889
+ if (el.errorCorrectionLevel !== undefined && !['L', 'M', 'Q', 'H'].includes(el.errorCorrectionLevel)) {
890
+ throw new PretextPdfError('VALIDATION_ERROR', `${prefix} (qr-code): 'errorCorrectionLevel' must be 'L', 'M', 'Q', or 'H'`);
891
+ }
892
+ if (el.size !== undefined && (typeof el.size !== 'number' || el.size <= 0 || !isFinite(el.size))) {
893
+ throw new PretextPdfError('VALIDATION_ERROR', `${prefix} (qr-code): 'size' must be a positive finite number`);
894
+ }
895
+ if (el.foreground !== undefined && !HEX_COLOR_REGEX.test(el.foreground)) {
896
+ throw new PretextPdfError('VALIDATION_ERROR', `${prefix} (qr-code): 'foreground' must be a 6-digit hex string like '#000000'`);
897
+ }
898
+ if (el.background !== undefined && !HEX_COLOR_REGEX.test(el.background)) {
899
+ throw new PretextPdfError('VALIDATION_ERROR', `${prefix} (qr-code): 'background' must be a 6-digit hex string like '#ffffff'`);
900
+ }
901
+ if (el.margin !== undefined && (typeof el.margin !== 'number' || el.margin < 0 || !isFinite(el.margin))) {
902
+ throw new PretextPdfError('VALIDATION_ERROR', `${prefix} (qr-code): 'margin' must be a non-negative finite number`);
903
+ }
904
+ if (el.align !== undefined && !['left', 'center', 'right'].includes(el.align)) {
905
+ throw new PretextPdfError('VALIDATION_ERROR', `${prefix} (qr-code): 'align' must be 'left', 'center', or 'right'`);
906
+ }
907
+ if (el.spaceAfter !== undefined && (typeof el.spaceAfter !== 'number' || el.spaceAfter < 0 || !isFinite(el.spaceAfter))) {
908
+ throw new PretextPdfError('VALIDATION_ERROR', `${prefix} (qr-code): 'spaceAfter' must be a non-negative finite number`);
909
+ }
910
+ if (el.spaceBefore !== undefined && (typeof el.spaceBefore !== 'number' || el.spaceBefore < 0 || !isFinite(el.spaceBefore))) {
911
+ throw new PretextPdfError('VALIDATION_ERROR', `${prefix} (qr-code): 'spaceBefore' must be a non-negative finite number`);
912
+ }
913
+ break;
914
+ }
915
+ case 'barcode': {
916
+ if (typeof el.symbology !== 'string' || el.symbology.trim().length === 0) {
917
+ throw new PretextPdfError('VALIDATION_ERROR', `${prefix} (barcode): 'symbology' must be a non-empty string (e.g. 'ean13', 'code128')`);
918
+ }
919
+ if (typeof el.data !== 'string' || el.data.trim().length === 0) {
920
+ throw new PretextPdfError('VALIDATION_ERROR', `${prefix} (barcode): 'data' must be a non-empty string`);
921
+ }
922
+ if (el.width !== undefined && (typeof el.width !== 'number' || el.width <= 0 || !isFinite(el.width))) {
923
+ throw new PretextPdfError('VALIDATION_ERROR', `${prefix} (barcode): 'width' must be a positive finite number`);
924
+ }
925
+ if (el.height !== undefined && (typeof el.height !== 'number' || el.height <= 0 || !isFinite(el.height))) {
926
+ throw new PretextPdfError('VALIDATION_ERROR', `${prefix} (barcode): 'height' must be a positive finite number`);
927
+ }
928
+ if (el.align !== undefined && !['left', 'center', 'right'].includes(el.align)) {
929
+ throw new PretextPdfError('VALIDATION_ERROR', `${prefix} (barcode): 'align' must be 'left', 'center', or 'right'`);
930
+ }
931
+ if (el.spaceAfter !== undefined && (typeof el.spaceAfter !== 'number' || el.spaceAfter < 0 || !isFinite(el.spaceAfter))) {
932
+ throw new PretextPdfError('VALIDATION_ERROR', `${prefix} (barcode): 'spaceAfter' must be a non-negative finite number`);
933
+ }
934
+ if (el.spaceBefore !== undefined && (typeof el.spaceBefore !== 'number' || el.spaceBefore < 0 || !isFinite(el.spaceBefore))) {
935
+ throw new PretextPdfError('VALIDATION_ERROR', `${prefix} (barcode): 'spaceBefore' must be a non-negative finite number`);
936
+ }
937
+ break;
938
+ }
939
+ case 'chart': {
940
+ if (el.spec === null || typeof el.spec !== 'object' || Array.isArray(el.spec)) {
941
+ throw new PretextPdfError('VALIDATION_ERROR', `${prefix} (chart): 'spec' must be a plain vega-lite specification object`);
942
+ }
943
+ if (el.width !== undefined && (typeof el.width !== 'number' || el.width <= 0 || !isFinite(el.width))) {
944
+ throw new PretextPdfError('VALIDATION_ERROR', `${prefix} (chart): 'width' must be a positive finite number`);
945
+ }
946
+ if (el.height !== undefined && (typeof el.height !== 'number' || el.height <= 0 || !isFinite(el.height))) {
947
+ throw new PretextPdfError('VALIDATION_ERROR', `${prefix} (chart): 'height' must be a positive finite number`);
948
+ }
949
+ if (el.caption !== undefined && typeof el.caption !== 'string') {
950
+ throw new PretextPdfError('VALIDATION_ERROR', `${prefix} (chart): 'caption' must be a string`);
951
+ }
952
+ if (el.align !== undefined && !['left', 'center', 'right'].includes(el.align)) {
953
+ throw new PretextPdfError('VALIDATION_ERROR', `${prefix} (chart): 'align' must be 'left', 'center', or 'right'`);
954
+ }
955
+ if (el.spaceAfter !== undefined && (typeof el.spaceAfter !== 'number' || el.spaceAfter < 0 || !isFinite(el.spaceAfter))) {
956
+ throw new PretextPdfError('VALIDATION_ERROR', `${prefix} (chart): 'spaceAfter' must be a non-negative finite number`);
957
+ }
958
+ if (el.spaceBefore !== undefined && (typeof el.spaceBefore !== 'number' || el.spaceBefore < 0 || !isFinite(el.spaceBefore))) {
959
+ throw new PretextPdfError('VALIDATION_ERROR', `${prefix} (chart): 'spaceBefore' must be a non-negative finite number`);
960
+ }
961
+ break;
962
+ }
771
963
  case 'list': {
772
964
  if (el.style !== 'ordered' && el.style !== 'unordered') {
773
965
  throw new PretextPdfError('VALIDATION_ERROR', `${prefix} (list): 'style' must be 'ordered' or 'unordered'`);
@@ -797,8 +989,8 @@ function validateElement(el, index, loadedFamilies) {
797
989
  if (typeof nested.text !== 'string' || nested.text.trim() === '') {
798
990
  throw new PretextPdfError('VALIDATION_ERROR', `${prefix} (list): items[${ii}].items[${ni}].text must be a non-empty string`);
799
991
  }
800
- if (nested.items && nested.items.length > 0) {
801
- throw new PretextPdfError('VALIDATION_ERROR', `${prefix} (list): items[${ii}].items[${ni}] has nested itemsonly 1 level of nesting is supported in Phase 2`);
992
+ if (nested.items !== undefined) {
993
+ throw new PretextPdfError('VALIDATION_ERROR', `${prefix} (list): items[${ii}].items[${ni}].items is not allowedmaximum nesting depth is 2 levels`);
802
994
  }
803
995
  }
804
996
  }
@@ -866,15 +1058,22 @@ function validateElement(el, index, loadedFamilies) {
866
1058
  if (span.url !== undefined && (typeof span.url !== 'string' || span.url.trim() === '')) {
867
1059
  throw new PretextPdfError('VALIDATION_ERROR', `${prefix} (rich-paragraph): spans[${si}].url must be a non-empty string if provided`);
868
1060
  }
1061
+ if (span.url !== undefined && typeof span.url === 'string')
1062
+ validateUrl(span.url, `${prefix} (rich-paragraph) spans[${si}].url`);
869
1063
  if (span.href !== undefined && (typeof span.href !== 'string' || span.href.trim() === '')) {
870
1064
  throw new PretextPdfError('VALIDATION_ERROR', `${prefix} (rich-paragraph): spans[${si}].href must be a non-empty string if provided`);
871
1065
  }
1066
+ if (span.href !== undefined && typeof span.href === 'string')
1067
+ validateUrl(span.href, `${prefix} (rich-paragraph) spans[${si}].href`);
872
1068
  if (span.url !== undefined && span.href !== undefined) {
873
1069
  throw new PretextPdfError('VALIDATION_ERROR', `${prefix} (rich-paragraph): spans[${si}] cannot have both 'url' and 'href' — use one or the other`);
874
1070
  }
875
1071
  if (span.verticalAlign !== undefined && span.verticalAlign !== 'superscript' && span.verticalAlign !== 'subscript') {
876
1072
  throw new PretextPdfError('VALIDATION_ERROR', `${prefix} (rich-paragraph): spans[${si}].verticalAlign must be "superscript" or "subscript"`);
877
1073
  }
1074
+ if (span.letterSpacing !== undefined && (typeof span.letterSpacing !== 'number' || span.letterSpacing < 0 || !isFinite(span.letterSpacing) || span.letterSpacing > 200)) {
1075
+ throw new PretextPdfError('VALIDATION_ERROR', `${prefix} (rich-paragraph): spans[${si}].letterSpacing must be a non-negative finite number and <= 200`);
1076
+ }
878
1077
  }
879
1078
  if (el.dir !== undefined && !['ltr', 'rtl', 'auto'].includes(el.dir)) {
880
1079
  throw new PretextPdfError('VALIDATION_ERROR', `${prefix} (rich-paragraph): 'dir' must be 'ltr', 'rtl', or 'auto'`);
@@ -936,6 +1135,19 @@ function validateElement(el, index, loadedFamilies) {
936
1135
  if (el.spaceBefore !== undefined && (typeof el.spaceBefore !== 'number' || el.spaceBefore < 0 || !isFinite(el.spaceBefore))) {
937
1136
  throw new PretextPdfError('VALIDATION_ERROR', `${prefix} (code): 'spaceBefore' must be a non-negative finite number`);
938
1137
  }
1138
+ if (el.language !== undefined && typeof el.language !== 'string') {
1139
+ throw new PretextPdfError('VALIDATION_ERROR', `${prefix} (code): 'language' must be a string`);
1140
+ }
1141
+ if (el.highlightTheme !== undefined) {
1142
+ if (typeof el.highlightTheme !== 'object' || el.highlightTheme === null) {
1143
+ throw new PretextPdfError('VALIDATION_ERROR', `${prefix} (code): 'highlightTheme' must be an object`);
1144
+ }
1145
+ for (const [k, v] of Object.entries(el.highlightTheme)) {
1146
+ if (v !== undefined && !HEX_COLOR_REGEX.test(v)) {
1147
+ throw new PretextPdfError('VALIDATION_ERROR', `${prefix} (code): highlightTheme.${k} must be a 6-digit hex string`);
1148
+ }
1149
+ }
1150
+ }
939
1151
  break;
940
1152
  }
941
1153
  case 'blockquote': {
@@ -1058,26 +1270,27 @@ function validateElement(el, index, loadedFamilies) {
1058
1270
  break;
1059
1271
  }
1060
1272
  case 'form-field': {
1273
+ const ff = el;
1061
1274
  const fieldTypes = ['text', 'checkbox', 'radio', 'dropdown', 'button'];
1062
- if (!fieldTypes.includes(el.fieldType)) {
1275
+ if (!fieldTypes.includes(ff.fieldType)) {
1063
1276
  throw new PretextPdfError('VALIDATION_ERROR', `[${index}] form-field.fieldType must be one of: ${fieldTypes.join(', ')}`);
1064
1277
  }
1065
- if (!el.name || el.name.trim() === '') {
1278
+ if (!ff.name || ff.name.trim() === '') {
1066
1279
  throw new PretextPdfError('VALIDATION_ERROR', `[${index}] form-field.name is required and must be a non-empty string`);
1067
1280
  }
1068
- if ((el.fieldType === 'radio' || el.fieldType === 'dropdown') && (!el.options || el.options.length === 0)) {
1069
- throw new PretextPdfError('VALIDATION_ERROR', `[${index}] form-field of type "${el.fieldType}" requires a non-empty options array`);
1281
+ if ((ff.fieldType === 'radio' || ff.fieldType === 'dropdown') && (!ff.options || ff.options.length === 0)) {
1282
+ throw new PretextPdfError('VALIDATION_ERROR', `[${index}] form-field of type "${ff.fieldType}" requires a non-empty options array`);
1070
1283
  }
1071
- if (el.width !== undefined && (typeof el.width !== 'number' || el.width <= 0)) {
1284
+ if (ff.width !== undefined && (typeof ff.width !== 'number' || ff.width <= 0)) {
1072
1285
  throw new PretextPdfError('VALIDATION_ERROR', `[${index}] form-field.width must be a positive number`);
1073
1286
  }
1074
- if (el.height !== undefined && (typeof el.height !== 'number' || el.height <= 0)) {
1287
+ if (ff.height !== undefined && (typeof ff.height !== 'number' || ff.height <= 0)) {
1075
1288
  throw new PretextPdfError('VALIDATION_ERROR', `[${index}] form-field.height must be a positive number`);
1076
1289
  }
1077
- if (el.borderColor !== undefined && !/^#[0-9A-Fa-f]{6}$/.test(el.borderColor)) {
1290
+ if (ff.borderColor !== undefined && !/^#[0-9A-Fa-f]{6}$/.test(ff.borderColor)) {
1078
1291
  throw new PretextPdfError('VALIDATION_ERROR', `[${index}] form-field.borderColor must be a 6-digit hex color`);
1079
1292
  }
1080
- if (el.backgroundColor !== undefined && !/^#[0-9A-Fa-f]{6}$/.test(el.backgroundColor)) {
1293
+ if (ff.backgroundColor !== undefined && !/^#[0-9A-Fa-f]{6}$/.test(ff.backgroundColor)) {
1081
1294
  throw new PretextPdfError('VALIDATION_ERROR', `[${index}] form-field.backgroundColor must be a 6-digit hex color`);
1082
1295
  }
1083
1296
  break;
@@ -1147,7 +1360,7 @@ function validateElement(el, index, loadedFamilies) {
1147
1360
  }
1148
1361
  default: {
1149
1362
  const type = el.type;
1150
- throw new PretextPdfError('VALIDATION_ERROR', `${prefix}: unknown element type '${String(type)}'. Valid types: 'paragraph', 'heading', 'spacer', 'table', 'image', 'svg', 'list', 'hr', 'page-break', 'code', 'rich-paragraph', 'blockquote', 'toc', 'comment', 'form-field', 'footnote-def'`);
1363
+ throw new PretextPdfError('VALIDATION_ERROR', `${prefix}: unknown element type '${String(type)}'. Valid types: 'paragraph', 'heading', 'spacer', 'table', 'image', 'svg', 'list', 'hr', 'page-break', 'code', 'rich-paragraph', 'blockquote', 'callout', 'toc', 'comment', 'form-field', 'footnote-def', 'float-group'`);
1151
1364
  }
1152
1365
  }
1153
1366
  }