formlift 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1 +1 @@
1
- {"version":3,"sources":["/home/harendra/Desktop/project/formlift/dist/server/index.cjs","../../src/server/completed-pdf.ts"],"names":[],"mappings":"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","file":"/home/harendra/Desktop/project/formlift/dist/server/index.cjs","sourcesContent":[null,"import {\n PDFArray,\n PDFDict,\n PDFDocument,\n PDFHexString,\n PDFName,\n PDFString,\n rgb,\n StandardFonts,\n type PDFFont,\n type PDFPage,\n type PDFRef,\n} from 'pdf-lib';\nimport { defaultLayout, fieldStates, type Answers, type FieldDef, type Layout, type LayoutElement } from '../core/form-logic';\n\n// The document as users see it, as a new PDF: the layout's pages (original pages copied, blank ones added), the\n// admin's text / boxes / lines, then the answers at their fields. The original file stays as it is.\n// ponytail: standard PDF fonts (WinAnsi) only, other characters print as '?'; embed a Unicode TTF when non-Latin\n// text matters. Rotated original pages (/Rotate) are not handled.\n\nconst printable = (font: PDFFont, s: string) =>\n [...s.replace(/\\r/g, '')]\n .map((ch) => {\n if (ch === '\\n') return ch;\n try {\n font.encodeText(ch);\n return ch;\n } catch {\n return '?';\n }\n })\n .join('');\n\nconst fromBase64 = (b64: string) => Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));\n\nconst hex = (c: string) =>\n rgb(parseInt(c.slice(1, 3), 16) / 255, parseInt(c.slice(3, 5), 16) / 255, parseInt(c.slice(5, 7), 16) / 255);\n\ntype Fonts = Record<string, PDFFont>;\nasync function loadFonts(doc: PDFDocument): Promise<Fonts> {\n const F = StandardFonts;\n const names: Record<string, StandardFonts> = {\n sans: F.Helvetica,\n 'sans-b': F.HelveticaBold,\n 'sans-i': F.HelveticaOblique,\n 'sans-bi': F.HelveticaBoldOblique,\n serif: F.TimesRoman,\n 'serif-b': F.TimesRomanBold,\n 'serif-i': F.TimesRomanItalic,\n 'serif-bi': F.TimesRomanBoldItalic,\n mono: F.Courier,\n 'mono-b': F.CourierBold,\n 'mono-i': F.CourierOblique,\n 'mono-bi': F.CourierBoldOblique,\n };\n const out: Fonts = {};\n for (const [k, v] of Object.entries(names)) out[k] = await doc.embedFont(v);\n return out;\n}\nconst fontOf = (fonts: Fonts, e: LayoutElement) =>\n fonts[`${e.font ?? 'sans'}${e.bold || e.italic ? '-' : ''}${e.bold ? 'b' : ''}${e.italic ? 'i' : ''}`];\n\n// A page-fraction box in PDF coordinates (origin bottom left) of the page's visible box.\nfunction boxOn(page: PDFPage, p: { x: number; y: number; width: number; height: number }) {\n const b = page.getCropBox();\n const w = p.width * b.width;\n const h = p.height * b.height;\n return { x: b.x + p.x * b.width, top: b.y + b.height - p.y * b.height, w, h };\n}\n\nfunction drawElement(page: PDFPage, e: LayoutElement, fonts: Fonts) {\n const { x, top, w, h } = boxOn(page, e.position);\n if (e.type === 'line') {\n page.drawLine({\n start: { x, y: top },\n end: { x: x + w, y: top - h },\n thickness: e.strokeWidth ?? 1,\n color: hex(e.stroke ?? '#000000'),\n });\n return;\n }\n if (e.fill || (e.type === 'box' && e.stroke))\n page.drawRectangle({\n x,\n y: top - h,\n width: w,\n height: h,\n ...(e.fill ? { color: hex(e.fill) } : {}),\n ...(e.stroke && (e.strokeWidth ?? 1) > 0 ? { borderColor: hex(e.stroke), borderWidth: e.strokeWidth ?? 1 } : {}),\n });\n if (e.type !== 'text' || !e.text) return;\n const font = fontOf(fonts, e);\n const size = e.fontSize ?? 10;\n const lineHeight = size * 1.2;\n // Wrap each paragraph to the box width, then align each line.\n const lines: string[] = [];\n for (const para of printable(font, e.text).split('\\n')) {\n let line = '';\n for (const word of para.split(' ')) {\n const next = line ? `${line} ${word}` : word;\n if (line && font.widthOfTextAtSize(next, size) > w) {\n lines.push(line);\n line = word;\n } else line = next;\n }\n lines.push(line);\n }\n lines.forEach((line, i) => {\n const lw = font.widthOfTextAtSize(line, size);\n const dx = e.align === 'center' ? (w - lw) / 2 : e.align === 'right' ? w - lw : 0;\n page.drawText(line, {\n x: x + dx,\n y: top - size * 0.95 - i * lineHeight,\n size,\n font,\n color: hex(e.color ?? '#000000'),\n });\n });\n}\n\n/** A file of the submission (upload or signature PNG), by its id = the answer's value. */\nexport interface AnswerFile {\n fileName: string;\n fileType: string;\n bytes: Uint8Array;\n}\n\ntype Embedded = [name: string, spec: PDFRef];\n\n/**\n * An uploaded file inside the PDF under its original name: listed in the viewer's attachments panel, and a click\n * on the field's box opens / saves it (a FileAttachment annotation with an empty look, so the file name drawn in\n * the box shows). Acrobat, Firefox and most desktop readers support this; Chrome's built-in viewer does not.\n */\nfunction embedFile(doc: PDFDocument, page: PDFPage, box: ReturnType<typeof boxOn>, file: AnswerFile, list: Embedded[]) {\n const ctx = doc.context;\n const stream = ctx.register(\n ctx.flateStream(file.bytes, {\n Type: 'EmbeddedFile',\n Subtype: PDFName.of(file.fileType || 'application/octet-stream'),\n Params: { Size: file.bytes.length },\n }),\n );\n const spec = ctx.register(\n ctx.obj({\n Type: 'Filespec',\n F: PDFString.of(file.fileName.replace(/[^\\x20-\\x7e]/g, '_')),\n UF: PDFHexString.fromText(file.fileName),\n EF: { F: stream, UF: stream },\n }),\n );\n list.push([file.fileName, spec]);\n const { x, top, w, h } = box;\n const look = ctx.register(ctx.formXObject([], { BBox: [0, 0, w, h] }));\n const annot = ctx.obj({\n Type: 'Annot',\n Subtype: 'FileAttachment',\n Rect: [x, top - h, x + w, top],\n FS: spec,\n Contents: PDFHexString.fromText(file.fileName),\n Name: 'Paperclip',\n F: 4, // print\n AP: { N: look },\n });\n page.node.addAnnot(ctx.register(annot));\n}\n\n// The document's attachments list (catalog /Names /EmbeddedFiles), sorted by name as the PDF spec asks;\n// files the original PDF already carried stay listed.\nfunction listAttachments(doc: PDFDocument, list: Embedded[]) {\n if (!list.length) return;\n const ctx = doc.context;\n const seen = new Map<string, number>();\n const entries = list.map(([name, spec]) => {\n const n = (seen.get(name) ?? 0) + 1;\n seen.set(name, n);\n return [n > 1 ? `${name} (${n})` : name, spec] as const;\n });\n entries.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));\n const names = doc.catalog.lookupMaybe(PDFName.of('Names'), PDFDict) ?? ctx.obj({});\n doc.catalog.set(PDFName.of('Names'), names);\n const earlier = names.lookupMaybe(PDFName.of('EmbeddedFiles'), PDFDict)?.lookupMaybe(PDFName.of('Names'), PDFArray);\n const flat = earlier ? earlier.asArray() : [];\n names.set(\n PDFName.of('EmbeddedFiles'),\n ctx.obj({ Names: [...flat, ...entries.flatMap(([n, ref]) => [PDFHexString.fromText(n), ref])] }),\n );\n}\n\n/**\n * The filled document as a new PDF: layout pages, the admin's text / boxes / lines, then each visible answer at\n * its field. `files` maps an answer's file id (file / signature fields) to its bytes. Runs in Node and browsers.\n */\nexport async function completedPdf(\n original: Uint8Array | ArrayBuffer,\n fields: FieldDef[],\n answers: Answers,\n files: Record<string, AnswerFile> = {},\n layout: Layout | null = null,\n): Promise<Uint8Array> {\n const src = await PDFDocument.load(original, { updateMetadata: false });\n const plan = layout ?? defaultLayout(src.getPages().map((p) => p.getSize()));\n const doc = layout ? await PDFDocument.create() : src;\n if (layout) {\n // Pages in the layout's order; an original page may appear more than once.\n const copied = await doc.copyPages(\n src,\n plan.pages.filter((p) => p.source != null).map((p) => p.source! - 1),\n );\n let next = 0;\n for (const p of plan.pages) doc.addPage(p.source != null ? copied[next++] : [p.width, p.height]);\n }\n const pages = doc.getPages();\n const fonts = await loadFonts(doc);\n for (const e of plan.elements) if (pages[e.page - 1]) drawElement(pages[e.page - 1], e, fonts);\n\n const font = fonts.sans;\n const states = fieldStates(fields, answers);\n const embedded: Embedded[] = [];\n for (const f of fields) {\n const v = answers[f.key];\n if (!states[f.key]?.visible || v == null || v === '' || v === false) continue;\n const page = pages[f.position.page - 1];\n if (!page) continue;\n const { x, top, w, h } = boxOn(page, f.position);\n\n if (f.type === 'signature' && typeof v === 'string') {\n // A stored PNG file; signatures saved before files were used are data URLs.\n const png = v.startsWith('data:') ? fromBase64(v.split(',')[1] ?? '') : files[v]?.bytes;\n if (!png) continue;\n const img = await doc.embedPng(png);\n const s = Math.min(w / img.width, h / img.height);\n page.drawImage(img, { x, y: top - h + (h - img.height * s) / 2, width: img.width * s, height: img.height * s });\n continue;\n }\n const file = f.type === 'file' ? files[String(v)] : undefined;\n if (file) embedFile(doc, page, { x, top, w, h }, file, embedded);\n const text = f.type === 'checkbox' ? 'X' : f.type === 'file' ? (file?.fileName ?? '') : String(v);\n const multiline = f.type === 'textarea';\n const size = Math.max(5, Math.min(11, h * (multiline ? 0.35 : 0.7)));\n page.drawText(printable(font, text), {\n x: x + 2,\n y: multiline ? top - size - 1 : top - h / 2 - size * 0.35,\n size,\n font,\n color: rgb(0.075, 0.306, 0.29),\n ...(multiline ? { maxWidth: w - 4, lineHeight: size * 1.2 } : {}),\n });\n }\n listAttachments(doc, embedded);\n return doc.save();\n}\n"]}
1
+ {"version":3,"sources":["/home/runner/work/formlift/formlift/dist/server/index.cjs","../../src/server/completed-pdf.ts"],"names":[],"mappings":"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","file":"/home/runner/work/formlift/formlift/dist/server/index.cjs","sourcesContent":[null,"import {\n PDFArray,\n PDFDict,\n PDFDocument,\n PDFHexString,\n PDFName,\n PDFString,\n rgb,\n StandardFonts,\n type PDFFont,\n type PDFPage,\n type PDFRef,\n} from 'pdf-lib';\nimport { defaultLayout, fieldStates, type Answers, type FieldDef, type Layout, type LayoutElement } from '../core/form-logic';\n\n// The document as users see it, as a new PDF: the layout's pages (original pages copied, blank ones added), the\n// admin's text / boxes / lines, then the answers at their fields. The original file stays as it is.\n// ponytail: standard PDF fonts (WinAnsi) only, other characters print as '?'; embed a Unicode TTF when non-Latin\n// text matters. Rotated original pages (/Rotate) are not handled.\n\nconst printable = (font: PDFFont, s: string) =>\n [...s.replace(/\\r/g, '')]\n .map((ch) => {\n if (ch === '\\n') return ch;\n try {\n font.encodeText(ch);\n return ch;\n } catch {\n return '?';\n }\n })\n .join('');\n\nconst fromBase64 = (b64: string) => Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));\n\nconst hex = (c: string) =>\n rgb(parseInt(c.slice(1, 3), 16) / 255, parseInt(c.slice(3, 5), 16) / 255, parseInt(c.slice(5, 7), 16) / 255);\n\ntype Fonts = Record<string, PDFFont>;\nasync function loadFonts(doc: PDFDocument): Promise<Fonts> {\n const F = StandardFonts;\n const names: Record<string, StandardFonts> = {\n sans: F.Helvetica,\n 'sans-b': F.HelveticaBold,\n 'sans-i': F.HelveticaOblique,\n 'sans-bi': F.HelveticaBoldOblique,\n serif: F.TimesRoman,\n 'serif-b': F.TimesRomanBold,\n 'serif-i': F.TimesRomanItalic,\n 'serif-bi': F.TimesRomanBoldItalic,\n mono: F.Courier,\n 'mono-b': F.CourierBold,\n 'mono-i': F.CourierOblique,\n 'mono-bi': F.CourierBoldOblique,\n };\n const out: Fonts = {};\n for (const [k, v] of Object.entries(names)) out[k] = await doc.embedFont(v);\n return out;\n}\nconst fontOf = (fonts: Fonts, e: LayoutElement) =>\n fonts[`${e.font ?? 'sans'}${e.bold || e.italic ? '-' : ''}${e.bold ? 'b' : ''}${e.italic ? 'i' : ''}`];\n\n// A page-fraction box in PDF coordinates (origin bottom left) of the page's visible box.\nfunction boxOn(page: PDFPage, p: { x: number; y: number; width: number; height: number }) {\n const b = page.getCropBox();\n const w = p.width * b.width;\n const h = p.height * b.height;\n return { x: b.x + p.x * b.width, top: b.y + b.height - p.y * b.height, w, h };\n}\n\nfunction drawElement(page: PDFPage, e: LayoutElement, fonts: Fonts) {\n const { x, top, w, h } = boxOn(page, e.position);\n if (e.type === 'line') {\n page.drawLine({\n start: { x, y: top },\n end: { x: x + w, y: top - h },\n thickness: e.strokeWidth ?? 1,\n color: hex(e.stroke ?? '#000000'),\n });\n return;\n }\n if (e.fill || (e.type === 'box' && e.stroke))\n page.drawRectangle({\n x,\n y: top - h,\n width: w,\n height: h,\n ...(e.fill ? { color: hex(e.fill) } : {}),\n ...(e.stroke && (e.strokeWidth ?? 1) > 0 ? { borderColor: hex(e.stroke), borderWidth: e.strokeWidth ?? 1 } : {}),\n });\n if (e.type !== 'text' || !e.text) return;\n const font = fontOf(fonts, e);\n const size = e.fontSize ?? 10;\n const lineHeight = size * 1.2;\n // Wrap each paragraph to the box width, then align each line.\n const lines: string[] = [];\n for (const para of printable(font, e.text).split('\\n')) {\n let line = '';\n for (const word of para.split(' ')) {\n const next = line ? `${line} ${word}` : word;\n if (line && font.widthOfTextAtSize(next, size) > w) {\n lines.push(line);\n line = word;\n } else line = next;\n }\n lines.push(line);\n }\n lines.forEach((line, i) => {\n const lw = font.widthOfTextAtSize(line, size);\n const dx = e.align === 'center' ? (w - lw) / 2 : e.align === 'right' ? w - lw : 0;\n page.drawText(line, {\n x: x + dx,\n y: top - size * 0.95 - i * lineHeight,\n size,\n font,\n color: hex(e.color ?? '#000000'),\n });\n });\n}\n\n/** A file of the submission (upload or signature PNG), by its id = the answer's value. */\nexport interface AnswerFile {\n fileName: string;\n fileType: string;\n bytes: Uint8Array;\n}\n\ntype Embedded = [name: string, spec: PDFRef];\n\n/**\n * An uploaded file inside the PDF under its original name: listed in the viewer's attachments panel, and a click\n * on the field's box opens / saves it (a FileAttachment annotation with an empty look, so the file name drawn in\n * the box shows). Acrobat, Firefox and most desktop readers support this; Chrome's built-in viewer does not.\n */\nfunction embedFile(doc: PDFDocument, page: PDFPage, box: ReturnType<typeof boxOn>, file: AnswerFile, list: Embedded[]) {\n const ctx = doc.context;\n const stream = ctx.register(\n ctx.flateStream(file.bytes, {\n Type: 'EmbeddedFile',\n Subtype: PDFName.of(file.fileType || 'application/octet-stream'),\n Params: { Size: file.bytes.length },\n }),\n );\n const spec = ctx.register(\n ctx.obj({\n Type: 'Filespec',\n F: PDFString.of(file.fileName.replace(/[^\\x20-\\x7e]/g, '_')),\n UF: PDFHexString.fromText(file.fileName),\n EF: { F: stream, UF: stream },\n }),\n );\n list.push([file.fileName, spec]);\n const { x, top, w, h } = box;\n const look = ctx.register(ctx.formXObject([], { BBox: [0, 0, w, h] }));\n const annot = ctx.obj({\n Type: 'Annot',\n Subtype: 'FileAttachment',\n Rect: [x, top - h, x + w, top],\n FS: spec,\n Contents: PDFHexString.fromText(file.fileName),\n Name: 'Paperclip',\n F: 4, // print\n AP: { N: look },\n });\n page.node.addAnnot(ctx.register(annot));\n}\n\n// The document's attachments list (catalog /Names /EmbeddedFiles), sorted by name as the PDF spec asks;\n// files the original PDF already carried stay listed.\nfunction listAttachments(doc: PDFDocument, list: Embedded[]) {\n if (!list.length) return;\n const ctx = doc.context;\n const seen = new Map<string, number>();\n const entries = list.map(([name, spec]) => {\n const n = (seen.get(name) ?? 0) + 1;\n seen.set(name, n);\n return [n > 1 ? `${name} (${n})` : name, spec] as const;\n });\n entries.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));\n const names = doc.catalog.lookupMaybe(PDFName.of('Names'), PDFDict) ?? ctx.obj({});\n doc.catalog.set(PDFName.of('Names'), names);\n const earlier = names.lookupMaybe(PDFName.of('EmbeddedFiles'), PDFDict)?.lookupMaybe(PDFName.of('Names'), PDFArray);\n const flat = earlier ? earlier.asArray() : [];\n names.set(\n PDFName.of('EmbeddedFiles'),\n ctx.obj({ Names: [...flat, ...entries.flatMap(([n, ref]) => [PDFHexString.fromText(n), ref])] }),\n );\n}\n\n/**\n * The filled document as a new PDF: layout pages, the admin's text / boxes / lines, then each visible answer at\n * its field. `files` maps an answer's file id (file / signature fields) to its bytes. Runs in Node and browsers.\n */\nexport async function completedPdf(\n original: Uint8Array | ArrayBuffer,\n fields: FieldDef[],\n answers: Answers,\n files: Record<string, AnswerFile> = {},\n layout: Layout | null = null,\n): Promise<Uint8Array> {\n const src = await PDFDocument.load(original, { updateMetadata: false });\n const plan = layout ?? defaultLayout(src.getPages().map((p) => p.getSize()));\n const doc = layout ? await PDFDocument.create() : src;\n if (layout) {\n // Pages in the layout's order; an original page may appear more than once.\n const copied = await doc.copyPages(\n src,\n plan.pages.filter((p) => p.source != null).map((p) => p.source! - 1),\n );\n let next = 0;\n for (const p of plan.pages) doc.addPage(p.source != null ? copied[next++] : [p.width, p.height]);\n }\n const pages = doc.getPages();\n const fonts = await loadFonts(doc);\n for (const e of plan.elements) if (pages[e.page - 1]) drawElement(pages[e.page - 1], e, fonts);\n\n const font = fonts.sans;\n const states = fieldStates(fields, answers);\n const embedded: Embedded[] = [];\n for (const f of fields) {\n const v = answers[f.key];\n if (!states[f.key]?.visible || v == null || v === '' || v === false) continue;\n const page = pages[f.position.page - 1];\n if (!page) continue;\n const { x, top, w, h } = boxOn(page, f.position);\n\n if (f.type === 'signature' && typeof v === 'string') {\n // A stored PNG file; signatures saved before files were used are data URLs.\n const png = v.startsWith('data:') ? fromBase64(v.split(',')[1] ?? '') : files[v]?.bytes;\n if (!png) continue;\n const img = await doc.embedPng(png);\n const s = Math.min(w / img.width, h / img.height);\n page.drawImage(img, { x, y: top - h + (h - img.height * s) / 2, width: img.width * s, height: img.height * s });\n continue;\n }\n const file = f.type === 'file' ? files[String(v)] : undefined;\n if (file) embedFile(doc, page, { x, top, w, h }, file, embedded);\n const text = f.type === 'checkbox' ? 'X' : f.type === 'file' ? (file?.fileName ?? '') : String(v);\n const multiline = f.type === 'textarea';\n const size = Math.max(5, Math.min(11, h * (multiline ? 0.35 : 0.7)));\n page.drawText(printable(font, text), {\n x: x + 2,\n y: multiline ? top - size - 1 : top - h / 2 - size * 0.35,\n size,\n font,\n color: rgb(0.075, 0.306, 0.29),\n ...(multiline ? { maxWidth: w - 4, lineHeight: size * 1.2 } : {}),\n });\n }\n listAttachments(doc, embedded);\n return doc.save();\n}\n"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "formlift",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Turn any PDF into a fillable web form: auto-detect fields, drag-and-drop builder, conditional logic, validation, fill-in UI and a completed-PDF generator. React UI + framework-agnostic core.",
5
5
  "keywords": [
6
6
  "pdf",
@@ -1 +0,0 @@
1
- {"version":3,"sources":["/home/harendra/Desktop/project/formlift/dist/chunk-5VHPTLLV.cjs","../src/core/coords.ts","../src/core/fields.ts","../src/core/layout.ts","../src/core/detect.ts","../src/core/questions.ts"],"names":["newId","top","right","bottom","t"],"mappings":"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EAAA;AACN,EAAA;AACE,IAAA;AACE,MAAA;AACA,MAAA;AACA,MAAA;AACF,IAAA;AACI,EAAA;AACN,EAAA;AACA,EAAA;AACE,IAAA;AACE,IAAA;AACE,IAAA;AACJ,IAAA;AACI,IAAA;AACN,EAAA;AACF;AAGM;AACJ,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACF;AACM;AAKA;AAKG;AACP,EAAA;AACE,IAAA;AACI,IAAA;AACJ,IAAA;AAME,MAAA;AACE,IAAA;AACA,IAAA;AACN,EAAA;AACF;AAES;AACD,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACN,EAAA;AACE,IAAA;AACA,IAAA;AACI,IAAA;AACJ,IAAA;AACF,EAAA;AACM,EAAA;AAIF,EAAA;AAGE,EAAA;AACN,EAAA;AAEE,IAAA;AACA,IAAA;AACE,MAAA;AACA,MAAA;AACA,MAAA;AACA,MAAA;AACA,MAAA;AAOA,MAAA;AACF,IAAA;AACF,EAAA;AACI,EAAA;AACJ,EAAA;AACI,IAAA;AACA,MAAA;AACA,MAAA;AACD,IAAA;AACL;AAKS;AACD,EAAA;AACD,EAAA;AACC,EAAA;AACA,EAAA;AACH,IAAA;AAEH,EAAA;AACM,EAAA;AACD,EAAA;AACH,EAAA;AACI,EAAA;AACR;AAGM;AACG;AACD,EAAA;AACF,EAAA;AACE,EAAA;AACF,EAAA;AACG,EAAA;AACT;AAiBa;AACX,EAAA;AACA,EAAA;AACA,EAAA;AACA,EAAA;AACM,EAAA;AACN,EAAA;AACO,EAAA;AACP,EAAA;AACA,EAAA;AACF;AAEa;AAGP;AAEG;AACH,EAAA;AACC,EAAA;AACC,EAAA;AACC,EAAA;AACT;AAKS;AACD,EAAA;AACA,EAAA;AACA,EAAA;AACC,EAAA;AACT;AACM;AAGU;AACR,EAAA;AACF,EAAA;AACG,EAAA;AACT;AAGgB;AACR,EAAA;AACF,EAAA;AACF,IAAA;AACI,EAAA;AACA,EAAA;AACC,EAAA;AACT;AAEgB;AACR,EAAA;AAEJ,EAAA;AAKA,IAAA;AACI,EAAA;AACA,EAAA;AACF,EAAA;AACE,EAAA;AACA,EAAA;AAEJ,EAAA;AAIJ;ALynBS;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA","file":"/home/harendra/Desktop/project/formlift/dist/chunk-5VHPTLLV.cjs","sourcesContent":[null,"import type { Position } from './form-logic'\n\n// Field positions are fractions (0..1) of their PDF page, measured from the page's top left. The overlay is\n// placed with CSS percentages, so zoom, window size and screen never move a field; only these conversions\n// touch pixels.\n\nconst clamp = (n: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, n))\nexport const MIN_SIZE = 0.01\n\n/** Snaps a fraction to the grid (`grid` = step as a fraction of the page, 0 = off). */\nexport const snap = (n: number, grid: number) => (grid > 0 ? Math.round(n / grid) * grid : n)\n\n/** A point on the page element (client pixels) as page fractions. */\nexport function toPage(\n clientX: number,\n clientY: number,\n rect: { left: number; top: number; width: number; height: number },\n) {\n return { x: clamp((clientX - rect.left) / rect.width, 0, 1), y: clamp((clientY - rect.top) / rect.height, 0, 1) }\n}\n\n/** Keeps a box inside its page and at least MIN_SIZE big. */\nexport function fitInPage(p: Position): Position {\n const width = clamp(p.width, MIN_SIZE, 1)\n const height = clamp(p.height, MIN_SIZE, 1)\n return { ...p, width, height, x: clamp(p.x, 0, 1 - width), y: clamp(p.y, 0, 1 - height) }\n}\n\n/** Moves a box by a fraction delta (dragged), snapped, kept inside the page. */\nexport function moveBy(p: Position, dx: number, dy: number, grid = 0): Position {\n return fitInPage({ ...p, x: snap(p.x + dx, grid), y: snap(p.y + dy, grid) })\n}\n\n/** Resizes from the bottom-right corner by a fraction delta, snapped, kept inside the page. */\nexport function resizeBy(p: Position, dw: number, dh: number, grid = 0): Position {\n const width = clamp(snap(p.x + p.width + dw, grid) - p.x, MIN_SIZE, 1 - p.x)\n const height = clamp(snap(p.y + p.height + dh, grid) - p.y, MIN_SIZE, 1 - p.y)\n return { ...p, width, height }\n}\n\n/** CSS that places a box on its page element. */\nexport const boxStyle = (p: Position) => ({\n left: `${p.x * 100}%`,\n top: `${p.y * 100}%`,\n width: `${p.width * 100}%`,\n height: `${p.height * 100}%`,\n})\n","import type { FieldDef, FieldType } from './form-logic'\nimport { fitInPage } from './coords'\n\n// Default box per type, in page fractions (an A4 page is ~0.6 wide in these units per 1/2 page).\nconst SIZES: Partial<Record<FieldType, [number, number]>> = {\n textarea: [0.4, 0.08],\n checkbox: [0.025, 0.018],\n radio: [0.35, 0.025],\n signature: [0.25, 0.06],\n}\n\nconst newId = () =>\n typeof crypto !== 'undefined' && crypto.randomUUID ? crypto.randomUUID() : `f${Date.now()}${Math.random()}`\n\n/** A key not used yet: text_1, text_2… */\nexport function uniqueKey(base: string, fields: FieldDef[]) {\n const used = new Set(fields.map((f) => f.key))\n let i = 1\n while (used.has(`${base}_${i}`)) i++\n return `${base}_${i}`\n}\n\n/** A field key from a label: \"IMO NUMBER\" → imo_number_1 (unique among `fields`). */\nexport function keyFor(label: string, type: FieldType, fields: { key: string }[]) {\n const slug = label\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, '_')\n .replace(/^_+|_+$/g, '')\n .slice(0, 40)\n const base = /^[a-z]/.test(slug) ? slug : `${type}${slug ? '_' + slug : ''}`\n const used = new Set(fields.map((f) => f.key))\n let i = 1\n while (used.has(`${base}_${i}`)) i++\n return `${base}_${i}`\n}\n\ntype Cond = FieldDef['visibleWhen']\nconst renameIn = (c: Cond, from: string, to: string): Cond =>\n c && {\n ...c,\n rules: c.rules.map((r) =>\n 'rules' in r ? renameIn(r, from, to)! : r.fieldKey === from ? { ...r, fieldKey: to } : r,\n ),\n }\n\n/**\n * `fields` with `f` put in place of the field with its id. Keys are never typed: a new label gives a new key\n * (unique, from the label), and every condition that used the old key follows it.\n */\nexport function withField(fields: FieldDef[], f: FieldDef): FieldDef[] {\n const old = fields.find((x) => x.id === f.id)\n if (!old) return fields\n const others = fields.filter((x) => x.id !== f.id)\n const key =\n old.label === f.label && old.type === f.type && !others.some((x) => x.key === f.key)\n ? f.key\n : keyFor(f.label, f.type, others)\n const next = { ...f, key }\n if (key === old.key) return fields.map((x) => (x.id === f.id ? next : x))\n return fields.map((x) => {\n const y = { ...(x.id === f.id ? next : x) }\n for (const k of ['visibleWhen', 'requiredWhen', 'readOnlyWhen'] as const)\n if (y[k]) y[k] = renameIn(y[k], old.key, key)\n return y\n })\n}\n\n/** A new manual field of `type`, centred on the dropped point. */\nexport function newField(type: FieldType, page: number, x: number, y: number, fields: FieldDef[]): FieldDef {\n const [width, height] = SIZES[type] ?? [0.25, 0.025]\n // Label \"Text\", key text_1: the same pair a label edit would give (see withField).\n const label = type[0].toUpperCase() + type.slice(1)\n return {\n id: newId(),\n key: keyFor(label, type, fields),\n type,\n label,\n source: 'manual',\n position: fitInPage({ page, x: x - width / 2, y: y - height / 2, width, height }),\n ...(type === 'dropdown' || type === 'radio' ? { options: ['Option 1', 'Option 2'] } : {}),\n }\n}\n\n/** A copy a little lower, with its own id and key. */\nexport function duplicate(f: FieldDef, fields: FieldDef[]): FieldDef {\n return {\n ...structuredClone(f),\n id: newId(),\n key: uniqueKey(f.key.replace(/_\\d+$/, ''), fields),\n source: 'manual',\n confidence: undefined,\n position: fitInPage({ ...f.position, y: f.position.y + f.position.height + 0.005 }),\n }\n}\n\n/** Moves the field at `from` one step up (-1) or down (+1) in the list = tab / fill order. */\nexport function reorder(fields: FieldDef[], id: string, step: -1 | 1) {\n const i = fields.findIndex((f) => f.id === id)\n const j = i + step\n if (i < 0 || j < 0 || j >= fields.length) return fields\n const out = [...fields]\n ;[out[i], out[j]] = [out[j], out[i]]\n return out\n}\n","import type { FieldDef, Layout, LayoutElement } from './form-logic'\nimport { fitInPage } from './coords'\n\n/** A field list and a layout, changed together (page moves renumber both). */\nexport interface Doc {\n fields: FieldDef[]\n layout: Layout\n}\n\nconst newId = () =>\n typeof crypto !== 'undefined' && crypto.randomUUID ? crypto.randomUUID() : `e${Date.now()}${Math.random()}`\n\n/** Renumbers every field and element through `map` (old page → new page, or null = gone with its page). */\nfunction remap(doc: Doc, pages: Layout['pages'], map: (page: number) => number | null): Doc {\n return {\n fields: doc.fields.flatMap((f) => {\n const page = map(f.position.page)\n return page == null ? [] : [{ ...f, position: { ...f.position, page } }]\n }),\n layout: {\n pages,\n elements: doc.layout.elements.flatMap((e) => {\n const page = map(e.page)\n return page == null ? [] : [{ ...e, page }]\n }),\n },\n }\n}\n\n/** Swaps page `n` (1-based) with its neighbour above (-1) or below (+1). */\nexport function movePage(doc: Doc, n: number, step: -1 | 1): Doc {\n const m = n + step\n if (m < 1 || m > doc.layout.pages.length) return doc\n const pages = [...doc.layout.pages]\n ;[pages[n - 1], pages[m - 1]] = [pages[m - 1], pages[n - 1]]\n return remap(doc, pages, (p) => (p === n ? m : p === m ? n : p))\n}\n\n/** Removes page `n` with the fields and items on it. */\nexport function deletePage(doc: Doc, n: number): Doc {\n if (doc.layout.pages.length <= 1) return doc\n const pages = doc.layout.pages.filter((_, i) => i !== n - 1)\n return remap(doc, pages, (p) => (p === n ? null : p > n ? p - 1 : p))\n}\n\n/** Adds a blank page after page `n` (0 = at the start), the size of page `n` (or of the first page). */\nexport function insertBlankPage(doc: Doc, n: number): Doc {\n const like = doc.layout.pages[Math.max(0, n - 1)] ?? { width: 595, height: 842 }\n const pages = [...doc.layout.pages]\n pages.splice(n, 0, { source: null, width: like.width, height: like.height })\n return remap(doc, pages, (p) => (p > n ? p + 1 : p))\n}\n\n/** What each layout tool draws when dropped. */\nexport const ELEMENT_TOOLS = {\n text: { label: 'Text', size: [0.3, 0.025], make: { type: 'text', text: 'New text', fontSize: 11 } },\n box: { label: 'Box', size: [0.3, 0.08], make: { type: 'box', stroke: '#000000', strokeWidth: 1 } },\n whiteout: { label: 'White-out (hide)', size: [0.2, 0.03], make: { type: 'box', fill: '#ffffff' } },\n line: { label: 'Line', size: [0.3, 0], make: { type: 'line', stroke: '#000000', strokeWidth: 1 } },\n} as const satisfies Record<string, { label: string; size: [number, number]; make: Partial<LayoutElement> }>\nexport type ElementTool = keyof typeof ELEMENT_TOOLS\n\n/** A new item from a tool, centred on the dropped point. */\nexport function newElement(tool: ElementTool, page: number, x: number, y: number): LayoutElement {\n const t = ELEMENT_TOOLS[tool]\n const [width, height] = t.size\n const p = fitInPage({ page, x: x - width / 2, y: y - height / 2, width, height: Math.max(height, 0.01) })\n return { id: newId(), page, ...t.make, position: { x: p.x, y: p.y, width, height } } as LayoutElement\n}\n\n/** A text item that hides an original line and shows `line.text` (editable) in its place. */\nexport function replaceLine(line: TextLine, page: number): LayoutElement {\n return {\n id: newId(),\n type: 'text',\n page,\n // A little bigger than the glyphs so no edge of the original shows through.\n position: fitInPage({\n page,\n x: line.x - 0.002,\n y: line.y - 0.002,\n width: Math.min(1, line.width + 0.006),\n height: Math.min(1, line.height + 0.004),\n }),\n text: line.text,\n fontSize: Math.round(line.fontSize * 10) / 10,\n fill: '#ffffff',\n replaces: line.text,\n ...(line.bold && { bold: true }),\n ...(line.italic && { italic: true }),\n }\n}\n\n/** Text width in points: canvas measureText in the browser; injectable for tests. */\nexport type Measure = (text: string, e: LayoutElement) => number\nlet canvas: HTMLCanvasElement | null = null\nexport const measureText: Measure = (text, e) => {\n canvas ??= document.createElement('canvas')\n const ctx = canvas.getContext('2d')\n if (!ctx) return 0\n const family = {\n sans: 'Helvetica, Arial, sans-serif',\n serif: 'Times New Roman, Times, serif',\n mono: 'Courier New, monospace',\n }\n ctx.font = `${e.italic ? 'italic ' : ''}${e.bold ? 'bold ' : ''}${e.fontSize ?? 10}px ${family[e.font ?? 'sans']}`\n return ctx.measureText(text).width\n}\n\n/**\n * Widens a text item to fit its longest line (never narrower, never past the page edge), so new text that is\n * longer than the line it replaces is not cut off. `pageWidth` in points.\n */\nexport function fitTextWidth(e: LayoutElement, pageWidth: number, measure: Measure = measureText): LayoutElement {\n if (e.type !== 'text' || !e.text) return e\n const longest = Math.max(...e.text.split('\\n').map((l) => measure(l, e)))\n const needed = (longest + 4) / pageWidth\n if (needed <= e.position.width) return e\n return { ...e, position: { ...e.position, width: Math.min(needed, 1 - e.position.x) } }\n}\n\nexport const duplicateElement = (e: LayoutElement): LayoutElement => ({\n ...structuredClone(e),\n id: newId(),\n replaces: undefined,\n position: { ...e.position, y: Math.min(1 - e.position.height, e.position.y + e.position.height + 0.005) },\n})\n\n// ---------- lines of text in the original PDF ----------\n\n/** One line (or column cell) of original text, in page fractions; fontSize in points. */\nexport interface TextLine {\n text: string\n x: number\n y: number\n width: number\n height: number\n fontSize: number\n bold?: boolean\n italic?: boolean\n}\n\n/** pdf.js text item: transform = [a, b, c, d, e, f] in PDF space, width in PDF units. */\nexport interface PdfTextItem {\n str: string\n transform: number[]\n width: number\n fontName?: string\n}\n\n/**\n * Groups pdf.js text items into lines. Items on the same baseline join when the gap between them is small (a\n * space); a wide gap (another column, a table cell) starts a new line. `viewBox` = [x0, y0, x1, y1] of the page.\n */\nexport function toLines(\n items: PdfTextItem[],\n viewBox: number[],\n /** The real font name of a pdf.js font id ('ABCDEF+Arial-BoldMT'), to tell bold / italic text. */\n fontOf: (id: string) => string | undefined = () => undefined,\n): TextLine[] {\n const [x0, y0, x1, y1] = viewBox\n const W = x1 - x0\n const H = y1 - y0\n const glyphs = items\n // Horizontal text only. Whitespace items are dropped: some PDFs fill the gap between columns with one wide\n // space item, which would join \"SHIP:\" and \"GT:\" into one line; the gap rule below adds word spaces back.\n .filter((i) => i.str.trim().length && Math.abs(i.transform[1]) < 0.01)\n .map((i) => {\n const size = Math.hypot(i.transform[2], i.transform[3]) || 1\n const font = (i.fontName && fontOf(i.fontName)) || ''\n return {\n str: i.str,\n x: i.transform[4] - x0,\n base: y1 - i.transform[5],\n w: i.width,\n size,\n bold: /bold|black|heavy|semibold|demi/i.test(font),\n italic: /italic|oblique/i.test(font),\n }\n })\n .sort((a, b) => a.base - b.base || a.x - b.x)\n\n const lines: { text: string; x: number; end: number; base: number; size: number; bold: boolean; italic: boolean }[] =\n []\n for (const g of glyphs) {\n const last = lines[lines.length - 1]\n const sameLine = last && Math.abs(g.base - last.base) < Math.max(g.size, last.size) * 0.4\n const gap = last ? g.x - last.end : Infinity\n if (sameLine && gap < Math.max(g.size, last.size) * 1.5 && gap > -last.size) {\n last.text += gap > g.size * 0.15 && !last.text.endsWith(' ') && !g.str.startsWith(' ') ? ' ' + g.str : g.str\n last.end = Math.max(last.end, g.x + g.w)\n last.size = Math.max(last.size, g.size)\n } else\n lines.push({ text: g.str, x: g.x, end: g.x + g.w, base: g.base, size: g.size, bold: g.bold, italic: g.italic })\n }\n return lines\n .map((l) => ({ ...l, text: l.text.trim() }))\n .filter((l) => l.text)\n .map((l) => ({\n text: l.text,\n x: l.x / W,\n y: (l.base - l.size * 0.92) / H,\n width: (l.end - l.x) / W,\n height: (l.size * 1.18) / H,\n fontSize: l.size,\n ...(l.bold && { bold: true }),\n ...(l.italic && { italic: true }),\n }))\n}\n","// Automatic field suggestions from a PDF page's drawing, no AI service needed:\n// - small stroked squares → checkbox\n// - empty cells of ruled tables → text area (or text when one line high)\n// - dotted / solid answer lines, \"____\" → text input (stacked lines → one text area)\n// Labels come from the text next to them (left of a line, beside a box, row + column headers of a cell).\n// Everything here works in PDF points with the origin at the page's top left; results are page fractions.\n// Suggestions get source 'ai' + a confidence, so the editor shows them as dashed boxes for the admin to check.\n\nimport type { FieldDef, FieldType } from './form-logic'\nimport { fitInPage } from './coords'\nimport { keyFor } from './fields'\nimport { toLines, type TextLine } from './layout'\n\nexport interface Seg {\n x0: number\n y0: number\n x1: number\n y1: number\n dashed: boolean\n}\nexport interface Rect {\n x: number\n y: number\n w: number\n h: number\n stroked: boolean\n filled: boolean\n /** Drawn with curves (a circle or rounded box). */\n round?: boolean\n}\nexport interface Shapes {\n hlines: Seg[]\n vlines: Seg[]\n rects: Rect[]\n}\n\ntype Matrix = [number, number, number, number, number, number]\nconst mul = (m: Matrix, n: number[]): Matrix => [\n m[0] * n[0] + m[2] * n[1],\n m[1] * n[0] + m[3] * n[1],\n m[0] * n[2] + m[2] * n[3],\n m[1] * n[2] + m[3] * n[3],\n m[0] * n[4] + m[2] * n[5] + m[4],\n m[1] * n[4] + m[3] * n[5] + m[5],\n]\n\n/** The pdf.js operator codes this needs (pdfjs `OPS`). */\nexport interface Ops {\n save: number\n restore: number\n transform: number\n setDash: number\n constructPath: number\n paintFormXObjectBegin: number\n paintFormXObjectEnd: number\n stroke: number\n closeStroke: number\n fill: number\n eoFill: number\n fillStroke: number\n eoFillStroke: number\n closeFillStroke: number\n closeEOFillStroke: number\n}\n// pdf.js DrawOPS inside a constructPath's path data.\nconst MOVE = 0\nconst LINE = 1\nconst CURVE = 2\nconst CLOSE = 3\n\n/**\n * Lines and rectangles a page draws, in points from the top left, from pdf.js `page.getOperatorList()`.\n * Follows the transform stack (save / restore / cm / form XObjects) and the dash state.\n */\nexport function extractShapes(fnArray: number[], argsArray: unknown[][], OPS: Ops, viewBox: number[]): Shapes {\n const [vx0, , , vy1] = viewBox\n const out: Shapes = { hlines: [], vlines: [], rects: [] }\n let state = { ctm: [1, 0, 0, 1, 0, 0] as Matrix, dashed: false }\n const stack: (typeof state)[] = []\n const strokeOps = new Set([\n OPS.stroke,\n OPS.closeStroke,\n OPS.fillStroke,\n OPS.eoFillStroke,\n OPS.closeFillStroke,\n OPS.closeEOFillStroke,\n ])\n const fillOps = new Set([\n OPS.fill,\n OPS.eoFill,\n OPS.fillStroke,\n OPS.eoFillStroke,\n OPS.closeFillStroke,\n OPS.closeEOFillStroke,\n ])\n\n for (let i = 0; i < fnArray.length; i++) {\n const fn = fnArray[i]\n const args = argsArray[i]\n if (fn === OPS.save) stack.push(state)\n else if (fn === OPS.restore) state = stack.pop() ?? state\n else if (fn === OPS.transform) state = { ...state, ctm: mul(state.ctm, args as number[]) }\n else if (fn === OPS.setDash)\n state = { ...state, dashed: Array.isArray(args[0]) && (args[0] as number[]).length > 0 }\n else if (fn === OPS.paintFormXObjectBegin) {\n stack.push(state)\n if (Array.isArray(args[0])) state = { ...state, ctm: mul(state.ctm, args[0] as number[]) }\n } else if (fn === OPS.paintFormXObjectEnd) state = stack.pop() ?? state\n else if (fn === OPS.constructPath) {\n const op = args[0] as number\n const path = (args[1] as unknown[] | undefined)?.[0] as ArrayLike<number> | undefined\n const stroked = strokeOps.has(op)\n const filled = fillOps.has(op)\n if ((!stroked && !filled) || !path || typeof path.length !== 'number') continue\n const m = state.ctm\n const pt = (x: number, y: number) => [m[0] * x + m[2] * y + m[4] - vx0, vy1 - (m[1] * x + m[3] * y + m[5])] // Split into sub-paths of points. A sub-path with curves is only kept as its box: a dot (part of a dotted\n // line) or a small round box (radio button / round checkbox).\n const subs: number[][][] = []\n const curved: number[][][] = []\n let cur: number[][] = []\n let isCurved = false\n const end = () => {\n if (cur.length) (isCurved ? curved : subs).push(cur)\n cur = []\n isCurved = false\n }\n for (let k = 0; k < path.length;) {\n const code = path[k++]\n if (code === MOVE) {\n end()\n cur = [pt(path[k++], path[k++])]\n } else if (code === LINE) cur.push(pt(path[k++], path[k++]))\n else if (code === CURVE) {\n cur.push(pt(path[k++], path[k++]), pt(path[k++], path[k++]), pt(path[k++], path[k++]))\n isCurved = true\n } else if (code === CLOSE) {\n if (cur.length) cur.push(cur[0])\n } else break\n }\n end()\n for (const c of curved) {\n const xs = c.map((p) => p[0])\n const ys = c.map((p) => p[1])\n const x = Math.min(...xs)\n const y = Math.min(...ys)\n out.rects.push({ x, y, w: Math.max(...xs) - x, h: Math.max(...ys) - y, stroked, filled, round: true })\n }\n for (const s of subs) {\n const xs = s.map((p) => p[0])\n const ys = s.map((p) => p[1])\n const x = Math.min(...xs)\n const y = Math.min(...ys)\n const w = Math.max(...xs) - x\n const h = Math.max(...ys) - y\n const axisAligned = s.every(\n (p, j) => j === 0 || Math.abs(p[0] - s[j - 1][0]) < 0.5 || Math.abs(p[1] - s[j - 1][1]) < 0.5,\n )\n if (s.length >= 4 && axisAligned && w > 0.5 && h > 0.5) {\n // A thin filled rectangle is how some tools draw a rule.\n if (filled && !stroked && h < 2 && w > 10)\n out.hlines.push({ x0: x, y0: y + h / 2, x1: x + w, y1: y + h / 2, dashed: false })\n else if (filled && !stroked && w < 2 && h > 10)\n out.vlines.push({ x0: x + w / 2, y0: y, x1: x + w / 2, y1: y + h, dashed: false })\n else out.rects.push({ x, y, w, h, stroked, filled })\n // (Not for checkbox-sized squares; a flat table row of 15 pt still counts.)\n if (stroked && (w > 16 || h > 16)) {\n // A stroked box's edges are rules too (table outlines).\n out.hlines.push(\n { x0: x, y0: y, x1: x + w, y1: y, dashed: state.dashed },\n { x0: x, y0: y + h, x1: x + w, y1: y + h, dashed: state.dashed },\n )\n out.vlines.push(\n { x0: x, y0: y, x1: x, y1: y + h, dashed: state.dashed },\n { x0: x + w, y0: y, x1: x + w, y1: y + h, dashed: state.dashed },\n )\n }\n continue\n }\n if (!stroked) continue\n for (let j = 1; j < s.length; j++) {\n const [ax, ay] = s[j - 1]\n const [bx, by] = s[j]\n if (Math.abs(ay - by) < 0.5 && Math.abs(bx - ax) > 1)\n out.hlines.push({ x0: Math.min(ax, bx), y0: ay, x1: Math.max(ax, bx), y1: ay, dashed: state.dashed })\n else if (Math.abs(ax - bx) < 0.5 && Math.abs(by - ay) > 1)\n out.vlines.push({ x0: ax, y0: Math.min(ay, by), x1: ax, y1: Math.max(ay, by), dashed: state.dashed })\n }\n }\n }\n }\n return withDottedLines(out)\n}\n\n/** Dotted lines drawn as rows of tiny dots or squares become one dashed line. */\nfunction withDottedLines(shapes: Shapes): Shapes {\n const isDot = (r: Rect) => r.w <= 2.5 && r.h <= 2.5\n const dots = shapes.rects.filter(isDot).sort((a, b) => a.y - b.y || a.x - b.x)\n if (dots.length < 5) return shapes\n const hlines = [...shapes.hlines]\n let run: Rect[] = []\n const flush = () => {\n if (run.length >= 5) {\n const y = run[0].y + run[0].h / 2\n hlines.push({ x0: run[0].x, x1: run[run.length - 1].x + run[run.length - 1].w, y0: y, y1: y, dashed: true })\n }\n run = []\n }\n for (const d of dots) {\n const last = run[run.length - 1]\n if (last && Math.abs(d.y - last.y) < 0.6 && d.x - (last.x + last.w) < 6) run.push(d)\n else {\n flush()\n run = [d]\n }\n }\n flush()\n return { ...shapes, hlines, rects: shapes.rects.filter((r) => !isDot(r)) }\n}\n\n// Characters fonts use for an empty / ticked box. Word and other tools often type them instead of drawing.\nconst BOX_CHARS = /[\\u2610\\u2611\\u2612\\u25A1\\u25A2\\u25FB\\u25FD\\u274F\\u2750\\u2751\\u2752]/\n\n/**\n * Splits checkbox characters (☐ ☑ □ ❏ …) out of pdf.js text items: each becomes a box, and the item keeps only\n * the rest of its text (shifted right), so \"☐ Loading\" gives a box and the label \"Loading\".\n */\nexport function splitBoxChars<T extends { str: string; transform: number[]; width: number }>(\n items: T[],\n viewBox: number[],\n): { items: T[]; boxes: Rect[] } {\n const [x0, , , y1] = viewBox\n const boxes: Rect[] = []\n const out: T[] = []\n for (const i of items) {\n if (!BOX_CHARS.test(i.str)) {\n out.push(i)\n continue\n }\n const chars = [...i.str]\n const per = i.width / Math.max(1, chars.length)\n const size = Math.hypot(i.transform[2], i.transform[3]) || 10\n let rest = ''\n let restStart = -1\n const flush = (end: number) => {\n if (rest.trim()) {\n const t = [...i.transform]\n t[4] += per * restStart\n out.push({ ...i, str: rest, width: per * (end - restStart), transform: t })\n }\n rest = ''\n restStart = -1\n }\n chars.forEach((ch, k) => {\n if (BOX_CHARS.test(ch)) {\n flush(k)\n const side = Math.min(per, size) * 0.85\n boxes.push({\n x: i.transform[4] - x0 + per * k + (per - side) / 2,\n y: y1 - i.transform[5] - size * 0.78,\n w: side,\n h: side,\n stroked: true,\n filled: false,\n })\n } else {\n if (restStart < 0) restStart = k\n rest += ch\n }\n })\n flush(chars.length)\n }\n return { items: out, boxes }\n}\n\n// ---------- detection ----------\n\n/** A text line in points (top-left origin). */\ninterface PtText {\n text: string\n x0: number\n y0: number\n x1: number\n y1: number\n}\ninterface Suggestion {\n type: FieldType\n label: string\n x: number\n y: number\n w: number\n h: number\n confidence: number\n}\n\nconst cluster = (values: number[], tol: number) => {\n const sorted = [...values].sort((a, b) => a - b)\n const out: number[] = []\n for (const v of sorted) if (!out.length || v - out[out.length - 1] > tol) out.push(v)\n return out\n}\nconst overlap = (a0: number, a1: number, b0: number, b1: number) => Math.max(0, Math.min(a1, b1) - Math.max(a0, b0))\n// Labels that say little on their own (\"Comments:\" under an item); named after the item above them.\nconst GENERIC = /^(comments?|remarks?|notes?|observations?|findings?)$/i\n// A label is short; a long sentence next to a line is prose, not the line's name. One that ends in \":\" is a\n// label whatever its length (shortened).\nconst cleanLabel = (s: string) => {\n const t = s\n .replace(/[:.\\s_]+$/, '')\n .replace(/^[\\s•▪\\-–☐☑☒□]+/, '')\n .trim()\n if (t.length <= 60) return t\n return /:\\s*$/.test(s) ? t.slice(0, 57).trimEnd() + '…' : ''\n}\n\nfunction typeFor(label: string): FieldType {\n if (/\\bsign(ature)?\\b/i.test(label)) return 'signature'\n if (/\\bdate\\b|\\bfrom\\b|\\bto\\b|\\bdob\\b/i.test(label)) return 'date'\n if (/e-?mail/i.test(label)) return 'email'\n if (/phone|mobile|\\btel\\b|contact no/i.test(label)) return 'phone'\n return 'text'\n}\n\n/**\n * Collinear pieces of one border joined into one line. Word draws a table's borders cell by cell and leaves\n * gaps of 2–3 pt where rows meet, so a column wall would otherwise never cover a whole row.\n */\nfunction joinSegs(lines: Seg[], vertical: boolean): Seg[] {\n const at = (l: Seg) => (vertical ? l.x0 : l.y0)\n const from = (l: Seg) => (vertical ? l.y0 : l.x0)\n const to = (l: Seg) => (vertical ? l.y1 : l.x1)\n const sorted = [...lines].sort((a, b) => at(a) - at(b) || from(a) - from(b))\n const out: Seg[] = []\n for (const l of sorted) {\n const last = out.find((o) => Math.abs(at(o) - at(l)) < 1 && from(l) <= to(o) + 3 && to(l) >= from(o))\n if (!last) out.push({ ...l })\n else if (vertical) last.y1 = Math.max(last.y1, l.y1)\n else last.x1 = Math.max(last.x1, l.x1)\n }\n return out\n}\n\n/** Cells of ruled tables: regions between solid lines that are closed on every side. */\nfunction tableCells(h: Seg[], v: Seg[], lastTextY = Infinity) {\n const TOL = 2\n const vs = joinSegs(\n v.filter((l) => !l.dashed && l.y1 - l.y0 > 8),\n true,\n )\n // A dotted line is an answer line, except where the table's walls end on it with nothing written below: the\n // open edge of a table that goes on to the next page.\n // (Walls that also start on it make it a dotted border between rows, which stays an answer line.)\n const across = (w: Seg, l: Seg) => w.x0 > l.x0 - TOL && w.x0 < l.x1 + TOL\n const edge = (l: Seg) =>\n l.y0 > lastTextY &&\n vs.filter((w) => across(w, l) && Math.abs(w.y1 - l.y0) < 1.5).length >= 2 &&\n !vs.some((w) => across(w, l) && Math.abs(w.y0 - l.y0) < 3)\n const hs = joinSegs(\n h.filter((l) => (!l.dashed || edge(l)) && l.x1 - l.x0 > 8),\n false,\n )\n if (hs.length < 2 || vs.length < 2) return []\n const xs = cluster(\n vs.map((l) => l.x0),\n TOL,\n )\n const ys = cluster(\n hs.map((l) => l.y0),\n TOL,\n )\n if (xs.length < 2 || ys.length < 2 || xs.length * ys.length > 20000) return []\n const covered = (lines: Seg[], at: number, from: number, to: number, horizontal: boolean) =>\n lines.some((l) =>\n horizontal\n ? Math.abs(l.y0 - at) <= TOL && l.x0 <= from + TOL && l.x1 >= to - TOL\n : Math.abs(l.x0 - at) <= TOL && l.y0 <= from + TOL && l.y1 >= to - TOL,\n )\n const C = xs.length - 1\n const R = ys.length - 1\n // Walls between neighbouring grid cells; union cells with no wall between them.\n const parent = Array.from({ length: C * R }, (_, i) => i)\n const find = (i: number): number => (parent[i] === i ? i : (parent[i] = find(parent[i])))\n const id = (c: number, r: number) => r * C + c\n for (let r = 0; r < R; r++)\n for (let c = 0; c < C; c++) {\n if (c + 1 < C && !covered(vs, xs[c + 1], ys[r], ys[r + 1], false)) parent[find(id(c, r))] = find(id(c + 1, r))\n if (r + 1 < R && !covered(hs, ys[r + 1], xs[c], xs[c + 1], true)) parent[find(id(c, r))] = find(id(c, r + 1))\n }\n const groups = new Map<number, { c0: number; c1: number; r0: number; r1: number; n: number }>()\n for (let r = 0; r < R; r++)\n for (let c = 0; c < C; c++) {\n const g = groups.get(find(id(c, r)))\n if (!g) groups.set(find(id(c, r)), { c0: c, c1: c, r0: r, r1: r, n: 1 })\n else\n Object.assign(g, {\n c0: Math.min(g.c0, c),\n c1: Math.max(g.c1, c),\n r0: Math.min(g.r0, r),\n r1: Math.max(g.r1, r),\n n: g.n + 1,\n })\n }\n const cells: { x0: number; y0: number; x1: number; y1: number }[] = []\n for (const g of groups.values()) {\n if (g.n !== (g.c1 - g.c0 + 1) * (g.r1 - g.r0 + 1)) continue // not a rectangle\n const x0 = xs[g.c0]\n const x1 = xs[g.c1 + 1]\n const y0 = ys[g.r0]\n const y1 = ys[g.r1 + 1]\n // Closed on all four sides, or it is the space around / between tables.\n if (\n covered(hs, y0, x0, x1, true) &&\n covered(hs, y1, x0, x1, true) &&\n covered(vs, x0, y0, y1, false) &&\n covered(vs, x1, y0, y1, false)\n )\n cells.push({ x0, y0, x1, y1 })\n }\n return cells\n}\n\n/**\n * Suggested fields for one page. `lines` = the page's text lines (utils/layout toLines, page fractions),\n * `underscores` = text runs of \"____\" (points), `existing` = fields already on the page (no duplicates),\n * `prevText` = the previous page's heading-like last line (names a box that starts this page).\n */\nexport function detectFields(\n shapes: Shapes,\n lines: TextLine[],\n size: { width: number; height: number },\n page: number,\n existing: FieldDef[] = [],\n underscores: Seg[] = [],\n prevText = '',\n): Omit<FieldDef, 'id' | 'key'>[] {\n const W = size.width\n const H = size.height\n const texts: PtText[] = lines.map((l) => ({\n text: l.text,\n x0: l.x * W,\n y0: l.y * H,\n x1: (l.x + l.width) * W,\n y1: (l.y + l.height) * H,\n }))\n // Sizes follow the page's own text: its typical line height (a form in 7 pt text has smaller boxes than one\n // in 14 pt). Without text, 10 pt type is assumed.\n const heights = texts.map((t) => t.y1 - t.y0).sort((a, b) => a - b)\n const lineH = Math.min(30, Math.max(9, (heights[Math.floor(heights.length / 2)] ?? 11.8) * 1.1))\n const em = lineH / 1.3\n const out: Suggestion[] = []\n const textIn = (x0: number, y0: number, x1: number, y1: number) =>\n texts.filter((t) => {\n const cx = (t.x0 + t.x1) / 2\n const cy = (t.y0 + t.y1) / 2\n return cx > x0 && cx < x1 && cy > y0 && cy < y1 && !/^_+$/.test(t.text)\n })\n\n // Ruled tables first: cells name the checkboxes and answer boxes inside them.\n const cells = tableCells(\n shapes.hlines,\n shapes.vlines,\n texts.filter((t) => !/^page \\d+/i.test(t.text)).reduce((m, t) => Math.max(m, t.y1), 0), // footer aside\n )\n type Cell = (typeof cells)[number]\n const textCells = cells.filter((c) => textIn(c.x0, c.y0, c.x1, c.y1).length)\n const cellText = (c: Cell) =>\n cleanLabel(\n textIn(c.x0, c.y0, c.x1, c.y1)\n .map((t) => t.text)\n .join(' '),\n )\n // \"Row – Column\": the nearest text cell on the left in the same row, and the table's header row (the topmost\n // text cell in the unbroken run of cells straight above, so another table above never names the column).\n // A header exactly as wide as the column wins over wider cells above it (the form's own header block, e.g.\n // \"Number of Observations\" above a \"No.\" column).\n // `longRow`: a long row name (a question) is shortened instead of dropped (checkboxes in Y / N / NA columns).\n const headersOf = (c: Cell, longRow = false) => {\n const colHead: Cell[] = []\n const sameCol = (t: Cell) => Math.abs(t.x0 - c.x0) < 2 && Math.abs(t.x1 - c.x1) < 2\n let emptyAbove = 0 // answer cells of the same column above (a section bar may cut the column)\n for (let up = c; ;) {\n const next = cells.find(\n (t) =>\n t.y0 < up.y0 - 1 && // strictly above: a cell under 2.5 pt tall would otherwise find itself forever\n Math.abs(t.y1 - up.y0) < 2.5 &&\n overlap(t.x0, t.x1, c.x0, c.x1) > 0.6 * Math.min(t.x1 - t.x0, c.x1 - c.x0),\n )\n if (!next) break\n if (textCells.includes(next)) colHead.push(next)\n else if (sameCol(next) && !colHead.some(sameCol)) emptyAbove++ // only between the cell and its header\n up = next\n }\n const rowHead = textCells\n .filter((t) => t.x1 <= c.x0 + 2 && overlap(t.y0, t.y1, c.y0, c.y1) > 0.6 * Math.min(t.y1 - t.y0, c.y1 - c.y0))\n .sort((a, b) => b.x1 - a.x1)\n // Only in a list (3+ empty rows stacked in the column); a lone blank cell keeps the old, stricter naming.\n let run = 1\n for (const down of [false, true])\n for (let cur = c; ;) {\n const n = cells.find(\n (t) =>\n sameCol(t) &&\n !textCells.includes(t) &&\n (down ? t.y0 > cur.y0 + 1 : t.y0 < cur.y0 - 1) &&\n Math.abs(down ? t.y0 - cur.y1 : cur.y0 - t.y1) < 2.5,\n )\n if (!n) break\n run++\n cur = n\n }\n const list = run >= 3 || emptyAbove >= 2\n const own = list ? colHead.filter((t) => sameCol(t) && cellText(t)) : []\n // A wide box right under its own heading bar (\"Suggestion / Feedback from Crew\" over an empty box).\n const bar = colHead[0]\n const underBar =\n bar && !rowHead.length && sameCol(bar) && Math.abs(bar.y1 - c.y0) < 2.5 && c.x1 - c.x0 > 0.5 * W && cellText(bar)\n const top = underBar ? bar : (own[0] ?? colHead[colHead.length - 1])\n const col = top ? cellText(top) : ''\n const rowRaw = rowHead.length\n ? textIn(rowHead[0].x0, rowHead[0].y0, rowHead[0].x1, rowHead[0].y1)\n .map((t) => t.text)\n .join(' ')\n : ''\n const row = cleanLabel(rowRaw) || (longRow && rowRaw ? rowRaw.slice(0, 57).trimEnd() + '…' : '')\n return [row, col].filter(Boolean).join(' – ')\n }\n const cellAt = (x: number, y: number) => cells.find((c) => x > c.x0 && x < c.x1 && y > c.y0 && y < c.y1)\n const proseRight = (c: Cell) =>\n textCells.some(\n (t) =>\n t.x0 >= c.x1 - 2 &&\n overlap(t.y0, t.y1, c.y0, c.y1) > 0.6 * (c.y1 - c.y0) &&\n textIn(t.x0, t.y0, t.x1, t.y1).reduce((n, x) => n + x.text.length, 0) > 80,\n )\n\n // 1. Checkboxes: small square outlines, named by the text beside them or, in a grid, by row and column.\n const minBox = Math.max(4, em * 0.45)\n const maxBox = Math.max(16, em * 1.8)\n const boxes = shapes.rects.filter(\n (r) =>\n r.stroked &&\n r.w >= minBox &&\n r.w <= maxBox &&\n r.h >= minBox &&\n r.h <= maxBox &&\n Math.abs(r.w - r.h) < Math.max(2.5, r.w * 0.2),\n )\n for (const b of boxes) {\n const cy = b.y + b.h / 2\n // In a ruled table only the box's own cell can name it (not the question in the cell beside it).\n const own = cellAt(b.x + b.w / 2, cy)\n const near = texts\n .filter((t) => cy > t.y0 - 2 && cy < t.y1 + 2)\n .filter((t) => !own || ((t.x0 + t.x1) / 2 > own.x0 && (t.x0 + t.x1) / 2 < own.x1))\n .map((t) => ({ t, gap: t.x0 >= b.x + b.w - 1 ? t.x0 - (b.x + b.w) : b.x - t.x1 }))\n .filter((n) => n.gap > -2 && n.gap < 40)\n .sort((p, q) => p.gap - q.gap)[0]\n const cell = near ? undefined : cellAt(b.x + b.w / 2, cy)\n out.push({\n type: 'checkbox',\n label: near ? cleanLabel(near.t.text) : cell ? headersOf(cell, true) : '',\n x: b.x - 1,\n y: b.y - 1,\n w: b.w + 2,\n h: b.h + 2,\n confidence: 0.9,\n })\n }\n\n // 2. Table cells without text, named by row and column.\n const fieldCells: Cell[] = []\n for (const c of cells) {\n if (c.x1 - c.x0 <= 20 || c.y1 - c.y0 <= 10 || textIn(c.x0, c.y0, c.x1, c.y1).length) continue\n if (boxes.some((b) => b.x > c.x0 && b.x < c.x1 && b.y > c.y0 && b.y < c.y1)) continue // a cell holding checkboxes\n // A blank cell in a column of checkboxes (Y / N / NA; a row continued from the previous page) is not a text box.\n const boxCells = cells.filter(\n (t) =>\n Math.abs(t.x0 - c.x0) < 2 &&\n Math.abs(t.x1 - c.x1) < 2 &&\n boxes.some((b) => b.x > t.x0 && b.x < t.x1 && b.y > t.y0 && b.y < t.y1),\n )\n if (boxCells.length >= 2) continue\n let label = headersOf(c)\n // A lone empty box (no table around it) a few lines tall is a free-text answer, named by the text right above\n // it, or by the end of the previous page when it starts the page (\"PART 1 – SUMMARY\" ... next page: the box).\n const lone = !cells.some(\n (t) =>\n t !== c &&\n ((overlap(t.x0, t.x1, c.x0, c.x1) > 2 && (Math.abs(t.y1 - c.y0) < 2.5 || Math.abs(t.y0 - c.y1) < 2.5)) ||\n (overlap(t.y0, t.y1, c.y0, c.y1) > 2 && (Math.abs(t.x1 - c.x0) < 2.5 || Math.abs(t.x0 - c.x1) < 2.5))),\n )\n if (!label && lone && c.y1 - c.y0 > lineH * 3 && c.x1 - c.x0 > em * 15) {\n const above = texts\n .filter((t) => t.y1 <= c.y0 + 1 && c.y0 - t.y1 < lineH * 4 && overlap(t.x0, t.x1, c.x0, c.x1) > 0)\n .sort((a, b) => b.y1 - a.y1)[0]\n const atTop = !texts.some((t) => t.y1 <= c.y0 + 1)\n label = above ? cleanLabel(above.text) : atTop ? cleanLabel(prevText) : ''\n if (!label && !atTop) continue\n label ||= 'Text'\n }\n // A row of answer cells (every cell in it blank) under a header too long to be a name (\"OPEN ITEMS, MEMORANDA,\n // … (Where Class records …)\"): the header's start names it.\n const head = cells.find(\n (t) =>\n textCells.includes(t) && Math.abs(t.y1 - c.y0) < 2.5 && Math.abs(t.x0 - c.x0) < 2 && Math.abs(t.x1 - c.x1) < 2,\n )\n const mates = cells.filter((t) => t !== c && Math.abs(t.y0 - c.y0) < 2 && Math.abs(t.y1 - c.y1) < 2)\n if (!label && head && mates.length && !mates.some((t) => textCells.includes(t))) {\n const raw = textIn(head.x0, head.y0, head.x1, head.y1)\n .map((t) => t.text)\n .join(' ')\n .replace(/\\s*\\(.*$/, '') // the note in brackets\n label =\n cleanLabel(raw) ||\n raw\n .slice(0, 58)\n .replace(/\\s+\\S*$/, '')\n .replace(/[,;:\\s]+$/, '') + '…'\n }\n // A blank cell beside a block of instructions is layout, not an answer box.\n const besideProse = textCells.some(\n (t) => t.x0 >= c.x1 - 2 && overlap(t.y0, t.y1, c.y0, c.y1) > 0.6 * (c.y1 - c.y0) && cellText(t).length === 0,\n )\n // A cell with no row or column name is spacing in the layout (beside headings, instructions), not an answer box.\n if (!label || besideProse || proseRight(c)) continue\n fieldCells.push(c)\n const tall = c.y1 - c.y0 > lineH * 2.2\n const pad = 1.5\n out.push({\n type: tall ? 'textarea' : typeFor(label),\n label,\n x: c.x0 + pad,\n y: c.y0 + pad,\n w: c.x1 - c.x0 - 2 * pad,\n h: c.y1 - c.y0 - 2 * pad,\n confidence: 0.75,\n })\n }\n\n // 3. Answer lines: dotted / solid rules with free space above, and \"____\" text (not inside a cell that is\n // already an answer box; a box with a question in it can still hold answer lines).\n const inAnyCell = (x: number, y: number) =>\n fieldCells.some((c) => x > c.x0 + 1 && x < c.x1 - 1 && y > c.y0 + 1 && y < c.y1 - 1)\n const isWall = (l: Seg) =>\n cells.some((c) => (Math.abs(l.y0 - c.y0) < 2 || Math.abs(l.y0 - c.y1) < 2) && l.x0 < c.x1 && l.x1 > c.x0)\n let rules = [...shapes.hlines, ...underscores]\n .filter((l) => l.x1 - l.x0 >= 25 && !isWall(l) && !inAnyCell((l.x0 + l.x1) / 2, l.y0))\n // Underlined words: text sits right on top of a solid rule (dotted rules are always answer lines).\n .filter(\n (l) =>\n l.dashed ||\n !texts.some(\n (t) => l.y0 - t.y1 > -3 && l.y0 - t.y1 < 4 && overlap(t.x0, t.x1, l.x0, l.x1) > 0.3 * (l.x1 - l.x0),\n ),\n )\n // A rule that crosses an inner column line of a table is a row border (some tables use dotted ones).\n .filter((l) => !shapes.vlines.some((v) => v.x0 > l.x0 + 4 && v.x0 < l.x1 - 4 && v.y0 < l.y0 + 1 && v.y1 > l.y0 - 1))\n .sort((a, b) => a.y0 - b.y0 || a.x0 - b.x0)\n // Pieces of one rule (dotted lines drawn in parts) join up.\n const joined: Seg[] = []\n for (const l of rules) {\n const last = joined.find((j) => Math.abs(j.y0 - l.y0) < 1 && l.x0 - j.x1 < 4 && l.x1 > j.x0)\n if (last) last.x1 = Math.max(last.x1, l.x1)\n else joined.push({ ...l })\n }\n rules = joined\n // Rules stacked under each other with the same width and nothing written between = one text area. Rules that\n // share their row with other rules are a grid (NAME / SIGNATURE / DATE rows): each row stays its own field.\n const inGrid = (l: Seg) => rules.some((r) => r !== l && Math.abs(r.y0 - l.y0) < 2)\n const used = new Set<Seg>()\n for (const l of rules) {\n if (used.has(l)) continue\n const stack = [l]\n for (const m of inGrid(l) ? [] : rules) {\n const last = stack[stack.length - 1]\n if (used.has(m) || stack.includes(m)) continue\n const gap = m.y0 - last.y0\n if (\n gap > 4 &&\n gap < lineH * 1.9 &&\n Math.abs(m.x0 - last.x0) < 4 &&\n Math.abs(m.x1 - last.x1) < 4 &&\n !textIn(m.x0, last.y0 + 1, m.x1, m.y0 - 1).length\n )\n stack.push(m)\n }\n stack.forEach((s) => used.add(s))\n const first = stack[0]\n const lastRule = stack[stack.length - 1]\n // Label: text on the same line to the left, or just above-left for a block.\n // Label: text on the same line to the left (with no other answer line in between), plus a column header\n // right above when the rule sits in a row of rules (RANK / NAME / SIGNATURE / DATE).\n const left = texts\n .filter((t) => t.x1 <= first.x0 + 6 && first.x0 - t.x1 < 220 && first.y0 >= t.y0 - 2 && first.y0 - t.y1 < 10)\n .filter(\n (t) =>\n !rules.some((r) => r !== first && Math.abs(r.y0 - first.y0) < 2 && r.x0 >= t.x1 - 1 && r.x1 <= first.x0 + 1),\n )\n .sort((a, b) => b.x1 - a.x1)[0]\n const rowOfRules = rules.filter((r) => Math.abs(r.y0 - first.y0) < 2).length >= 3\n // A solid rule across most of the page is a separator unless a \"Label:\" right before it names it; dotted\n // rules are always answer lines.\n const wide = !first.dashed && first.x1 - first.x0 > 0.7 * W\n if (wide && !/:\\s*$/.test(left?.text ?? '')) continue\n const header =\n (rowOfRules || !left) && !wide\n ? texts\n .filter((t) => t.y1 <= first.y0 - 2 && first.y0 - t.y1 < (rowOfRules ? 250 : 90))\n .filter(\n (t) =>\n (t.x0 + t.x1) / 2 > first.x0 &&\n (t.x0 + t.x1) / 2 < first.x1 &&\n t.x1 - t.x0 < (first.x1 - first.x0) * 1.2,\n )\n .sort((a, b) => b.y1 - a.y1)\n : []\n // A header written on two lines (\"DATE\" over \"(Reviewed)\") is read as one.\n const head = header[0]\n const headAbove = head && header.find((t) => t !== head && head.y0 - t.y1 < 6 && head.y0 >= t.y1 - 1)\n const label = [left, headAbove, head]\n .filter((t) => t)\n .map((t) => cleanLabel(t!.text))\n .filter(Boolean)\n .join(' – ')\n // An unnamed line across the page is a border (e.g. the bottom of a table), not an answer line.\n if (!label && first.x1 - first.x0 > 0.7 * W) continue\n // Top: about one line above the rule, but below any text above it.\n const above = texts\n .filter((t) => t.y1 <= first.y0 - 1 && overlap(t.x0, t.x1, first.x0, first.x1) > 2 && first.y0 - t.y1 < lineH)\n .reduce((m, t) => Math.max(m, t.y1), -Infinity)\n const top = Math.max(first.y0 - lineH, above + 0.5)\n // The next row starts right under it, from its left end: a separator between rows, not an answer line.\n if (texts.some((t) => t.y0 - lastRule.y0 > -1 && t.y0 - lastRule.y0 < 4 && Math.abs(t.x0 - first.x0) < 8)) continue\n // No room to write above it (text right on top): a separator under a paragraph, not an answer line. if (lastRule.y0 - top < lineH * 0.6) continue\n const multi = stack.length > 1\n out.push({\n type: multi ? 'textarea' : typeFor(label),\n label,\n x: first.x0,\n y: top,\n w: first.x1 - first.x0,\n h: lastRule.y0 - top,\n confidence: label ? 0.8 : 0.5,\n })\n }\n\n // 4. \"Label:\" followed by empty space up to the border of the ruled box it is in. Outside a box a line\n // ending in \":\" is usually a heading (\"Steps:\", \"Result:\"), so it is left alone.\n // A box with only \"Finding\" / \"Remarks\" / \"Comments\" written in it (no \":\"), taller than two lines, is an answer\n // box too, unless it heads a column of blank cells (a table's \"Remarks\" column header).\n const soleWord = (t: PtText) => {\n if (!GENERIC.test(cleanLabel(t.text))) return false\n const midY = (t.y0 + t.y1) / 2\n const cell = cells.find((c) => t.x0 > c.x0 - 1 && t.x1 < c.x1 + 1 && midY > c.y0 && midY < c.y1)\n if (!cell || cell.y1 - cell.y0 < lineH * 2.2 || textIn(cell.x0, cell.y0, cell.x1, cell.y1).length !== 1)\n return false\n return !cells.some(\n (b) =>\n Math.abs(b.y0 - cell.y1) < 2.5 &&\n Math.abs(b.x0 - cell.x0) < 2 &&\n Math.abs(b.x1 - cell.x1) < 2 &&\n !textIn(b.x0, b.y0, b.x1, b.y1).length,\n )\n }\n for (const t of texts) {\n if (!(/:\\s*$/.test(t.text) || soleWord(t)) || t.text.length > 40) continue\n // The last line of a wrapped label (\"Structural concerns / and Class Survey / File:\") is not a label of its own.\n const prev = texts.find(\n (o) => o !== t && Math.abs(o.x0 - t.x0) < 2 && t.y0 - o.y1 > -2 && t.y0 - o.y1 < lineH * 0.6,\n )\n if (prev && !/:\\s*$/.test(prev.text) && !GENERIC.test(cleanLabel(t.text))) continue\n const midY = (t.y0 + t.y1) / 2\n if (out.some((o) => midY > o.y - 2 && midY < o.y + o.h + 2 && o.x >= t.x1 - 4 && o.x - t.x1 < 250)) continue\n const cell = cells.find((c) => t.x0 > c.x0 - 1 && t.x1 < c.x1 + 1 && midY > c.y0 && midY < c.y1)\n if (!cell) continue\n // A box full of prose (a list of guidance or findings) holds no questions: \"… including:\" is a sentence.\n // (\"Comments:\" under an item is a real answer place however long the item.)\n const prose = textIn(cell.x0, cell.y0, cell.x1, cell.y1).filter((o) => o.text.length > 60).length >= 3\n if (prose && !GENERIC.test(cleanLabel(t.text))) continue\n const right = Math.min(\n cell.x1 - 2,\n ...texts.filter((o) => o !== t && o.x0 > t.x1 && overlap(o.y0, o.y1, t.y0, t.y1) > 0).map((o) => o.x0 - 4),\n ...shapes.vlines.filter((v) => v.x0 > t.x1 + 2 && v.y0 < midY && v.y1 > midY).map((v) => v.x0 - 2),\n )\n const top = Math.max(cell.y0 + 1.5, t.y0 - 2)\n const bottom = Math.min(\n cell.y1 - 1.5,\n ...texts.filter((o) => o.y0 > t.y1 && overlap(o.x0, o.x1, t.x1, right) > 0).map((o) => o.y0 - 1),\n )\n const x = t.x1 + 4\n if (right - x < em * 5 || bottom - top < lineH * 0.6 || textIn(x, top, right, bottom).length) continue\n // A heading bar (label on a filled band) is not a question.\n if (\n shapes.rects.some(\n (r) =>\n r.filled && !r.stroked && r.x <= t.x0 && r.x + r.w >= right - 2 && r.y <= t.y0 + 1 && r.y + r.h >= t.y1 - 1,\n )\n )\n continue\n let label = cleanLabel(t.text)\n // \"Comments:\" / \"Remarks:\" say little on their own: name them after the numbered item above them.\n if (GENERIC.test(label)) {\n const item = texts\n .filter((o) => o.y1 <= t.y0 + 1 && t.y0 - o.y1 < 400 && /^\\d+(\\.\\d+)+[a-z]?\\b/i.test(o.text))\n .sort((a, b) => b.y1 - a.y1)[0]\n // A box with only \"Finding\" in it: the block it closes, when a box right above starts with a short heading\n // (\"HW\", \"PR\", \"HF\"): one Finding box per block, each with its own name.\n let tag = ''\n for (let y = cell.y0, k = /:\\s*$/.test(t.text) ? 3 : 0; k < 3 && !tag; k++) {\n const block = cells.find((c) => Math.abs(c.y1 - y) < 2.5 && t.x0 > c.x0 - 1 && t.x0 < c.x1)\n if (!block) break\n const head = textIn(block.x0, block.y0, block.x1, block.y1).sort((p, q) => p.y0 - q.y0 || p.x0 - q.x0)[0]\n if (head && head.text.trim().length <= 12 && head.y0 - block.y0 < lineH * 1.5) tag = head.text.trim()\n y = block.y0\n }\n if (item) label = `${item.text.slice(0, 60).trim()}${tag ? ` – ${tag}` : ''} – ${label}`\n }\n out.push({\n type: bottom - top > lineH * 2.2 ? 'textarea' : typeFor(label),\n label,\n x,\n y: top,\n w: right - x,\n h: bottom - top,\n confidence: 0.65,\n })\n }\n\n // 5. Tables drawn with shading only (Word tables without borders): a shaded label cell followed by empty space\n // in its row is an answer box, split by the shaded column headers above it (\"Signature:\" / \"Date:\").\n const shade = shapes.rects\n .filter((r) => r.filled && !r.stroked && !r.round && r.h >= lineH * 0.8 && r.h <= lineH * 4 && r.w > em * 2)\n // Word paints a cell and its paragraph: keep the outer one (and one of exact duplicates).\n .filter(\n (r, i, all) =>\n !all.some(\n (o, j) =>\n j !== i &&\n o.x <= r.x + 0.5 &&\n o.y <= r.y + 0.5 &&\n o.x + o.w >= r.x + r.w - 0.5 &&\n o.y + o.h >= r.y + r.h - 0.5 &&\n (o.w * o.h > r.w * r.h + 1 || j < i),\n ),\n )\n const shadeText = (r: Rect) => textIn(r.x, r.y, r.x + r.w, r.y + r.h)\n const labelled = shade.filter((r) => shadeText(r).length)\n for (const L of labelled) {\n const label = cleanLabel(\n shadeText(L)\n .map((t) => t.text)\n .join(' '),\n )\n if (!label) continue\n const y0 = L.y\n const y1 = L.y + L.h\n const x0 = L.x + L.w\n const inRow = (a0: number, a1: number) => overlap(a0, a1, y0, y1) > 0.5 * Math.min(a1 - a0, L.h)\n // Up to the next label / text / checkbox in the row, else the right edge of the shaded block around it (the\n // shaded rects joined to it, row by row; a lone shaded title has no block).\n const block = [L]\n for (let k = 0; k < block.length; k++) {\n const s = block[k]\n for (const r of shade)\n if (\n !block.includes(r) &&\n overlap(r.x, r.x + r.w, s.x, s.x + s.w) > 0 &&\n (Math.abs(r.y - (s.y + s.h)) < 3 || Math.abs(s.y - (r.y + r.h)) < 3)\n )\n block.push(r)\n else if (!block.includes(r) && Math.abs(r.y - s.y) < 2 && Math.abs(r.h - s.h) < 2) block.push(r) // same row\n }\n const end = Math.min(\n Math.max(x0, ...block.map((r) => r.x + r.w)),\n ...labelled.filter((r) => r !== L && r.x >= x0 - 1 && inRow(r.y, r.y + r.h)).map((r) => r.x),\n ...texts.filter((t) => t.x0 >= x0 - 1 && inRow(t.y0, t.y1)).map((t) => t.x0 - 4),\n ...boxes.filter((b) => b.x >= x0 - 1 && inRow(b.y, b.y + b.h)).map((b) => b.x - 4),\n )\n if (end - x0 < em * 4) continue\n const heads = labelled.filter(\n (r) => r.y + r.h <= y0 + 1 && y0 - (r.y + r.h) < 120 && r.x >= x0 - 2 && r.x + r.w <= end + 2,\n )\n const headY = Math.max(...heads.map((r) => r.y + r.h))\n const cols = heads.filter((r) => Math.abs(r.y + r.h - headY) < 2).sort((a, b) => a.x - b.x)\n const split = cols.length && cols.reduce((n, r) => n + r.w, 0) > 0.8 * (end - x0)\n const parts = split\n ? cols.map((r) => ({\n a: Math.max(x0, r.x),\n b: Math.min(end, r.x + r.w),\n name: cleanLabel(\n shadeText(r)\n .map((t) => t.text)\n .join(' '),\n ),\n }))\n : [{ a: x0, b: end, name: '' }]\n for (const p of parts) {\n const name = [label, p.name].filter(Boolean).join(' – ')\n out.push({\n type: y1 - y0 > lineH * 2.2 ? 'textarea' : typeFor(p.name || label),\n label: name,\n x: p.a + 1.5,\n y: y0 + 1,\n w: p.b - p.a - 3,\n h: y1 - y0 - 2,\n confidence: 0.6,\n })\n }\n }\n\n // Page fractions; skip what overlaps an existing field or an earlier suggestion.\n const taken = existing.filter((f) => f.position.page === page).map((f) => f.position)\n const result: Omit<FieldDef, 'id' | 'key'>[] = []\n for (const s of out) {\n if (s.w < 4 || s.h < 4) continue\n const position = fitInPage({ page, x: s.x / W, y: s.y / H, width: s.w / W, height: s.h / H })\n const clash = taken.some((p) => {\n const inter =\n overlap(p.x, p.x + p.width, position.x, position.x + position.width) *\n overlap(p.y, p.y + p.height, position.y, position.y + position.height)\n return inter > 0.3 * Math.min(p.width * p.height, position.width * position.height)\n })\n if (clash) continue\n taken.push(position)\n result.push({ type: s.type, label: s.label, source: 'ai', confidence: s.confidence, position })\n }\n return result\n}\n\n/**\n * \"____\" runs inside text items, as answer lines (points, top-left origin), and the items without them, so\n * \"Signature: ______\" gives the line and the label \"Signature:\". Assumes even character widths.\n */\nexport function splitUnderscores<T extends { str: string; transform: number[]; width: number }>(\n items: T[],\n viewBox: number[],\n): { items: T[]; rules: Seg[] } {\n const [x0, , , y1] = viewBox\n const rules: Seg[] = []\n const out: T[] = []\n for (const i of items) {\n if (!i.str.includes('____')) {\n out.push(i)\n continue\n }\n const per = i.width / i.str.length\n let from = 0\n for (const m of i.str.matchAll(/_{4,}/g)) {\n const at = m.index ?? 0\n const start = i.transform[4] - x0 + per * at\n const y = y1 - i.transform[5] + 1\n // Typed, so treated like a dotted line (never an underline under words).\n rules.push({ x0: start, x1: start + per * m[0].length, y0: y, y1: y, dashed: true })\n const piece = i.str.slice(from, at)\n if (piece.trim()) {\n const t = [...i.transform]\n t[4] += per * from\n out.push({ ...i, str: piece, width: per * piece.length, transform: t })\n }\n from = at + m[0].length\n }\n const tail = i.str.slice(from)\n if (tail.trim()) {\n const t = [...i.transform]\n t[4] += per * from\n out.push({ ...i, str: tail, width: per * tail.length, transform: t })\n }\n }\n return { items: out, rules }\n}\n\n// ---------- whole document ----------\n\nexport { keyFor }\n\n/** What detection needs from a pdf.js document (PDFDocumentProxy). */\ninterface PdfDoc {\n getPage(n: number): Promise<{\n view: number[]\n getOperatorList(o?: { intent?: string }): Promise<{ fnArray: number[]; argsArray: unknown[] }>\n getTextContent(): Promise<{ items: unknown[] }>\n }>\n}\n\n/**\n * Suggested fields for every page of a layout (original pages only; blank pages have nothing to detect).\n * Runs page by page so the page stays responsive; `onProgress(done, total)`; stop early with `signal`.\n * New fields get ids and keys; existing fields are never duplicated.\n */\nexport async function detectDocument(\n pdf: PdfDoc,\n OPS: Ops,\n pages: { source: number | null; width: number; height: number }[],\n existing: FieldDef[],\n onProgress?: (done: number, total: number) => void,\n signal?: AbortSignal,\n): Promise<FieldDef[]> {\n const found: FieldDef[] = []\n let prevText = ''\n for (let i = 0; i < pages.length; i++) {\n if (signal?.aborted) break\n const p = pages[i]\n if (!p.source) prevText = ''\n else {\n const page = await pdf.getPage(p.source)\n // 'print' intent: its operator list is not the one the canvas renderer caches and rewrites.\n const ops = await page.getOperatorList({ intent: 'print' })\n const text = await page.getTextContent()\n const split = splitBoxChars(text.items as { str: string; transform: number[]; width: number }[], page.view)\n const typed = splitUnderscores(split.items, page.view)\n const items = typed.items\n const drawn = extractShapes(ops.fnArray, ops.argsArray as unknown[][], OPS, page.view)\n const shapes = { ...drawn, rects: [...drawn.rects, ...split.boxes] }\n const size = { width: page.view[2] - page.view[0], height: page.view[3] - page.view[1] }\n const all = [...existing, ...found]\n const lines = toLines(items, page.view)\n for (const s of detectFields(shapes, lines, size, i + 1, all, typed.rules, prevText)) {\n const id =\n typeof crypto !== 'undefined' && crypto.randomUUID ? crypto.randomUUID() : `d${Date.now()}${Math.random()}`\n found.push({ ...s, id, key: keyFor(s.label, s.type, [...existing, ...found]), label: s.label || s.type })\n }\n // The page's last lines, bottom up: a heading under a note (\"PART 1 – SUMMARY\" / \"(concise overview…)\").\n const bottom = [...lines].sort((a, b) => b.y - a.y).slice(0, 2)\n prevText = bottom.map((l) => l.text).find((t) => t.length <= 60 || /:\\s*$/.test(t)) ?? ''\n }\n onProgress?.(i + 1, pages.length)\n }\n return found\n}\n","// The form as a list of questions (the List view): fields on one line of the document that belong together —\n// \"2.1.1 Were the Master… – Y / N / NA\" and the Remarks box beside them — become one question, so it can be\n// searched and filtered as a whole. Grids of boxes and tables are one question each, laid out in the document's\n// rows and columns. Titles are the document's own text when the page text is given.\nimport type { AnswerValue, Answers, FieldDef, FieldType } from './form-logic'\nimport type { TextLine } from './layout'\n\nexport interface Question {\n id: string\n title: string\n page: number\n parts: { field: FieldDef; name: string }[]\n /** Option grid or table: parts per line of the document, to lay them out in the same rows. */\n rows?: number[]\n /** Table: its column names (parts are row by row, left to right), and each row's own name when rows have one. */\n columns?: string[]\n rowNames?: string[]\n /** The category heading the question sits under (e.g. \"HW\", \"PR\", \"HF\" in inspection checklists), when the form has them. */\n category?: string\n /** The numbered question heading it answers (\"2.1.1 Maintenance of statutory Certificates\"), when written above. */\n section?: string\n /** A column of boxes named by the text block left of it (its options come first). */\n block?: boolean\n /** \"Classification: Core , Risk: H , Assigned to: MASTER\" written in its numbered question: filters. */\n tags?: Record<string, string>\n /** What a run of rows with the same choices asks (\"Evaluation of the level of compliance…\"), shown over them. */\n intro?: string\n}\n\n/** Text lines of a form page (page fractions, top-left origin, as field positions), when known. */\nexport type PageText = (page: number) => TextLine[] | undefined\n\n// \"Row – Column\" labels (auto-detect, or typed that way): the row is the question, the column names the part.\nconst SEP = ' – '\nconst rowOf = (label: string) => (label.includes(SEP) ? label.slice(0, label.lastIndexOf(SEP)) : null)\nconst colOf = (label: string) => (label.includes(SEP) ? label.slice(label.lastIndexOf(SEP) + SEP.length) : label)\n// A field named only like a column (Y, No, Remarks…) belongs to the question on its line.\n// A row number: \"1\", \"2.\", \"3)\", \"a)\", \"iv.\"\nconst SERIAL = /^(\\d{1,3}|[a-z]|[ivx]{1,5})[.)]?$/i\nconst COLUMN = /^(y|n|n ?a|yes|no|remarks?|comments?|observations?|notes?)$/i\n\n// \"LAST PSC INSPECTION: Place\": a question and the name of its first part.\nconst LEAD = /^(.+?):\\s+(\\S.*)$/\n\nconst byX = (a: FieldDef, b: FieldDef) => a.position.x - b.position.x\nconst norm = (s: string) =>\n s\n .replace(/[:.\\s…]+$/, '')\n .trim()\n .toLowerCase()\nconst top = (fs: FieldDef[]) => Math.min(...fs.map((f) => f.position.y))\nconst bottom = (fs: FieldDef[]) => Math.max(...fs.map((f) => f.position.y + f.position.height))\nconst left = (fs: FieldDef[]) => Math.min(...fs.map((f) => f.position.x))\nconst right = (fs: FieldDef[]) => Math.max(...fs.map((f) => f.position.x + f.position.width))\n\n// The question written on the fields' line, left of them: the nearest column of text (not the item number or\n// category columns further left), in reading order.\nfunction rowText(lines: TextLine[], fs: FieldDef[]) {\n const y0 = top(fs)\n const y1 = bottom(fs)\n const x = left(fs)\n // The fields' own names written beside them (\"Yes ☐ No ☐\") are not the question.\n const own = new Set(fs.map((f) => norm(f.label)))\n const beside = lines.filter(\n (l) =>\n l.y + l.height / 2 > y0 - 0.002 &&\n l.y + l.height / 2 < y1 + 0.002 &&\n l.x + l.width <= x + 0.005 &&\n !own.has(norm(l.text)),\n )\n if (!beside.length) return ''\n const nearest = beside.reduce((a, b) => (b.x + b.width > a.x + a.width ? b : a))\n return beside\n .filter((l) => l.x >= nearest.x - 0.02)\n .sort((a, b) => a.y - b.y || a.x - b.x)\n .map((l) => l.text.trim())\n .join(' ')\n .replace(/\\s+/g, ' ')\n}\n\n// A table's title: the nearest line above its first row that is at least half as wide as the row (a title or\n// heading bar, not a single column header).\nfunction tableTitle(lines: TextLine[], first: FieldDef[]) {\n const y0 = top(first)\n const span = right(first) - left(first)\n const above = lines.filter((l) => l.y + l.height <= y0 + 0.002).sort((a, b) => b.y - a.y)\n let at: TextLine | undefined = above.find((l) => y0 - l.y < 0.15 && l.width >= 0.5 * span)\n // Up through the lines right above it (a note under the title runs on for lines): the block's first line.\n for (let cur = at; cur; cur = at) {\n const c: TextLine = cur\n at = above.find((l) => l.y < c.y - 0.001 && c.y - (l.y + l.height) < 0.6 * c.height) ?? c\n if (at === c) break\n }\n return at?.text.trim() ?? ''\n}\n\n// A grid of options (\"Loading ☐ Discharging ☐ …\"): lines of bare checkboxes named only by their own text. Its\n// heading is the line right above the grid with no field on it (\"OPERATIONS AT THE TIME OF INSPECTION:\"), else\n// the text left of the boxes on their line (\"If the ship was detained… ☐ Verified ☐ N/A\").\n// A \" – \" inside a name (\"Log-24a (Job Orders – Engine)\") is part of the name when no two boxes share the part\n// before it: a real \"Row – Column\" line repeats its row (\"2.1.1 … – Y\", \"2.1.1 … – N\").\nconst isOptions = (line: FieldDef[]) => {\n const rows = line.map((f) => rowOf(f.label)).filter(Boolean)\n const shared = rows.length > 0 && (line.length < 2 || new Set(rows).size < rows.length)\n return (\n !shared &&\n line.every(\n (f) =>\n (f.type === 'checkbox' || f.type === 'radio') &&\n !COLUMN.test(f.label.trim()) &&\n !COLUMN.test(colOf(f.label).trim()),\n )\n )\n}\n\nfunction heading(lines: TextLine[], line: FieldDef[], fields: FieldDef[]) {\n const y0 = top(line)\n const h = bottom(line) - y0\n const names = new Set(fields.map((f) => norm(f.label)))\n const free = (l: TextLine) => {\n const mid = l.y + l.height / 2\n return !names.has(norm(l.text)) && !fields.some((f) => mid > f.position.y && mid < f.position.y + f.position.height)\n }\n // The grid's width: its boxes and their names written beside them. A heading sits over it, not off to a side\n // (\"Practical Guidelines\" centred over a column of text is not the heading of boxes at the left edge).\n const own = new Set(line.map((f) => norm(f.label)))\n const named = lines.filter((l) => own.has(norm(l.text)) && l.y + l.height > y0 && l.y < y0 + h)\n const x0 = Math.min(left(line), ...named.map((l) => l.x))\n const x1 = Math.max(right(line), ...named.map((l) => l.x + l.width))\n // Text running on to the right of the boxes on their line (\"☐ Human The Inspector is expected to…\"): the boxes\n // are a side column of a text block, not a grid of options.\n if (\n lines.some(\n (l) => !own.has(norm(l.text)) && l.x >= x1 - 0.002 && l.y + l.height / 2 > y0 && l.y + l.height / 2 < y0 + h,\n )\n )\n return null\n const upFrom = (y: number, gap: (l: TextLine) => number) =>\n lines\n .filter((l) => l.y + l.height <= y + 0.002 && y - (l.y + l.height) < gap(l) && l.x < x1 && l.x + l.width > x0)\n .sort((a, b) => b.y - a.y)[0]\n const above = upFrom(y0, (l) => 1.5 * Math.max(h, l.height))\n if (!above || !free(above)) return rowText(lines, line)\n // A heading of a few lines (\"Qualifications\" over \"(tick all boxes that apply)\"): the lines set tight above.\n const block = [above]\n for (let l = above; block.length < 3;) {\n const up = upFrom(l.y, (u) => 0.6 * u.height)\n if (!up || !free(up)) break\n block.unshift((l = up))\n }\n return block.map((l) => l.text.trim()).join(' ')\n}\n\n// A table's header row: the line of text right above its first row, one name over each column (\"Training\n// Topic\", \"Name/Rank\", \"Date\"). Only when every column has its own name there; a single column's name is centred\n// over it (the last line of a paragraph above starts at the margin instead).\nfunction headerRow(lines: TextLine[], first: FieldDef[]) {\n const y0 = top(first)\n const h = bottom(first) - y0\n const above = lines.filter((l) => l.y + l.height <= y0 + 0.002 && y0 - (l.y + l.height) < 1.5 * Math.max(h, l.height))\n const at = Math.max(...above.map((l) => l.y))\n const row = above.filter((l) => Math.abs(l.y - at) < 0.5 * l.height)\n const names = first.map((f) => {\n const over = row.filter((l) => {\n const mid = l.x + l.width / 2\n return mid > f.position.x && mid < f.position.x + f.position.width\n })\n const one = over.length === 1 ? over[0] : null\n const centred =\n one && Math.abs(one.x + one.width / 2 - (f.position.x + f.position.width / 2)) < 0.1 * f.position.width\n return one && (first.length > 1 || centred) ? one.text.trim() : ''\n })\n return names.every(Boolean) ? names : null\n}\n\n/** Questions in document order: page, then top to bottom. */\nexport function toQuestions(fields: FieldDef[], textOf: PageText = () => undefined): Question[] {\n const sorted = [...fields].sort(\n (a, b) => a.position.page - b.position.page || a.position.y - b.position.y || a.position.x - b.position.x,\n )\n // Columns of boxes beside a block of text (\"A. Vessel is free of: A1… A4…\" │ ☐ Yes ☐ No ☐ NA): one question each.\n const stacks = findStacks(sorted, textOf)\n const stacked = new Set(stacks.flatMap((s) => s.fields))\n // Lines: fields that overlap the first field of the line vertically by half the smaller height.\n const lines: FieldDef[][] = []\n for (const f of sorted.filter((x) => !stacked.has(x))) {\n const line = lines[lines.length - 1]\n const head = line?.[0]\n const p = f.position\n const q = head?.position\n const shared = q ? Math.min(p.y + p.height, q.y + q.height) - Math.max(p.y, q.y) : 0\n if (q && q.page === p.page && shared >= 0.5 * Math.min(p.height, q.height)) line.push(f)\n else lines.push([f])\n }\n // Tables: lines of fields named by their columns (\"RANK\", \"NAME\", \"SIGNATURE\", \"DATE\"), repeated on the line\n // above or below with the same columns. A row may carry the table's side heading on one field (\"INSPECTOR\n // – RANK\"); lines whose fields all share a row name (\"2.1.1 … – Y / N\") are questions, not table rows.\n // Numbered rows (\"1 – Type of Training Need\", or a box named just \"1.\"): the number is the row's name, so\n // lines of them are a table (one column when each line has one field), not questions named \"1\", \"2\"…\n const serial = (f: FieldDef) => {\n const r = (rowOf(f.label) ?? f.label).trim()\n return SERIAL.test(r) && !COLUMN.test(r) ? r : ''\n }\n const column = (f: FieldDef) => (rowOf(f.label) ? colOf(f.label).trim() : serial(f) ? '' : f.label.trim())\n const sig = (line: FieldDef[]) =>\n (line.length >= 2 && line.filter((f) => rowOf(f.label)).length <= 1) ||\n (line.length > 0 && !!serial(line[0]) && line.every((f) => serial(f) === serial(line[0])))\n ? `${line[0].position.page}:${line.map(column).sort().join('|')}`\n : ''\n // Line i carries on the table of line i - 1: same columns, and stacked right under it (rows of a table touch;\n // a gap of more than a row is another table, even with the same columns).\n const follows = (i: number) => {\n const [a, b] = [lines[i - 1], lines[i]]\n return !!a && !!b && !!sig(b) && sig(a) === sig(b) && top(b) - bottom(a) < 1.5 * (bottom(a) - top(a))\n }\n const inTable = lines.map((_, i) => follows(i) || follows(i + 1))\n\n const out: Question[] = []\n // Sections of option grids: a grid's first line finds the heading, the lines right under it share it.\n // A line of boxes with no heading at all (\"☐ Full time ☐ Part time ☐ Contract\") is one question named by its\n // options, and the next line does not join it (no heading says they belong together).\n const section: (string | undefined)[] = lines.map(() => undefined)\n const headed: boolean[] = lines.map(() => false)\n lines.forEach((line, i) => {\n if (inTable[i] || !isOptions(line)) return\n const prev = lines[i - 1]\n const page = line[0].position.page\n // Rows of the same grid sit closer than 1.5 box heights apart (double-spaced lists too).\n if (\n headed[i - 1] &&\n prev[0].position.page === page &&\n top(line) - bottom(prev) < 1.5 * (bottom(prev) - top(prev))\n ) {\n section[i] = section[i - 1]\n headed[i] = true\n return\n }\n const text = heading(\n textOf(page) ?? [],\n line,\n sorted.filter((f) => f.position.page === page),\n )\n if (text === null) return\n if (text) [section[i], headed[i]] = [text, true]\n else if (line.length > 1)\n section[i] = [...line]\n .sort(byX)\n .map((f) => f.label || f.key)\n .join(' / ')\n })\n\n lines.forEach((line, i) => {\n const page = line[0].position.page\n const text = textOf(page) ?? []\n // An option grid is one question under its heading; each box is named by its own label.\n const grid = section[i]\n if (grid) {\n const parts = [...line].sort(byX).map((field) => ({ field, name: field.label || field.key }))\n const last = out[out.length - 1]\n if (grid === section[i - 1]) {\n last.parts.push(...parts)\n last.rows!.push(parts.length)\n } else out.push({ id: line[0].id, title: grid, page, parts, rows: [parts.length] })\n return\n }\n // A table is one question laid out as on the document: columns, rows, each row's name on its left.\n if (inTable[i]) {\n const list = [...line].sort(byX)\n const parts = list.map((field) => ({ field, name: column(field) }))\n const name = serial(list[0]) || (list.map((f) => rowOf(f.label)).find(Boolean) ?? rowText(text, list))\n if (!follows(i))\n out.push({\n id: list[0].id,\n title: tableTitle(text, line),\n page,\n parts,\n rows: [parts.length],\n columns: parts.map((p) => p.name),\n rowNames: [name],\n })\n else {\n const t = out[out.length - 1]\n t.parts.push(...parts)\n t.rows!.push(parts.length)\n t.rowNames!.push(name)\n }\n if (follows(i + 1)) return\n // Done: titled by the table's title, else its one side heading (\"INSPECTOR\"), else its columns.\n const t = out[out.length - 1]\n // Column names repeated, or the table's own title, were not read from the column headers: not shown.\n const cols = t.columns!\n if (cols.some((c, k) => c && (cols.indexOf(c) !== k || cols.lastIndexOf(c) !== k || norm(c) === norm(t.title))))\n t.columns = cols.map(() => '')\n // No names in the labels (\"1\", \"1\", \"1\"): the header row written over the table, when there is one.\n if (t.columns!.every((c) => !c))\n t.columns =\n headerRow(\n text,\n t.parts.slice(0, t.rows![0]).map((p) => p.field),\n ) ?? t.columns\n // One named column (\"Best Practices/Safety Related Ideas Identified\"): the column's name is the table's.\n if (t.columns!.length === 1 && t.columns![0]) [t.title, t.columns] = [t.columns![0], ['']]\n const named = t.rowNames!.filter(Boolean)\n if (!t.title && named.length === 1 && t.rowNames![0]) t.title = named[0]\n if (named.length === 0 || t.title === t.rowNames![0]) delete t.rowNames\n t.title ||= t.columns!.filter(Boolean).join(' / ') || 'List'\n return\n }\n const groups = new Map<string, FieldDef[]>()\n const loose: FieldDef[] = []\n // \"LAST PSC INSPECTION: Place ____ Date ____\": the fields after a \"Question: Part\" field on its line are\n // parts of that question, until another field with its own \"…:\".\n let lead = ''\n for (const f of [...line].sort(byX)) {\n const r = rowOf(f.label)\n const stem = LEAD.exec(f.label.trim())\n if (!r && stem) lead = `\\u0001${stem[1]}`\n else if (r || f.label.includes(':') || COLUMN.test(f.label.trim())) lead = ''\n if (r) groups.set(r, [...(groups.get(r) ?? []), f])\n else if (lead) groups.set(lead, [...(groups.get(lead) ?? []), f])\n else if (COLUMN.test(f.label.trim())) loose.push(f)\n else groups.set(`\\u0000${f.id}`, [f]) // a question of its own (\"Vessel\")\n }\n // Loose column fields join the line's first \"Row – Column\" question, else make one together.\n const host = [...groups.keys()].find((k) => !k.startsWith('\\u0000') && !k.startsWith('\\u0001'))\n if (host) groups.get(host)!.push(...loose)\n else if (loose.length) groups.set(loose.map((f) => f.label).join(' / '), loose) // \"Yes / No\"\n for (const [key, group] of groups) {\n const list = [...group].sort(byX) // left to right, as on the page\n // A name made of the line's column fields (\"Yes / No\"): no question text of its own.\n const led = key.startsWith('\\u0001')\n if (led) {\n const name = (f: FieldDef) => LEAD.exec(f.label.trim())?.[2] ?? (f.label || f.key)\n out.push({\n id: list[0].id,\n title: key.slice(1),\n page,\n parts: list.map((field) => ({ field, name: name(field) })),\n })\n continue\n }\n const generic = !key.startsWith('\\u0000') && list.every((f) => !rowOf(f.label))\n let title = key.startsWith('\\u0000') ? list[0].label || list[0].key : key\n // The document's own words: in place of names made of columns (\"Yes / No\"), or where they carry on a label\n // that was cut short (\"Were the Master and senior…\" → the whole question). Never a different text.\n const written = rowText(text, list)\n const stem = norm(title).replace(/^\\d+(\\.\\d+)*[a-z]?\\s+/, '') // \"2.1.1 Were the…\": the number may sit in its own column\n if (written && (generic || (norm(written).startsWith(stem) && norm(written).length > stem.length)))\n title = written\n out.push({\n id: list[0].id,\n title,\n page,\n parts: list.map((field) => ({\n field,\n name: list.length === 1 && !rowOf(field.label) ? '' : colOf(field.label) || field.key,\n })),\n })\n }\n })\n // Boxes named only by their own label (\"Human\", \"Process\") in a row of a table whose text runs on to their\n // right: the row's text says what the box is about.\n for (const q of out) {\n if (q.rows || q.columns || !q.parts.every((p) => p.field.type === 'checkbox' || p.field.type === 'radio')) continue\n if (!q.parts.some((p) => norm(p.field.label) === norm(q.title))) continue\n const text = sideText(\n textOf(q.page) ?? [],\n q.parts.map((p) => p.field),\n sorted.filter((f) => f.position.page === q.page),\n )\n if (!text) continue\n q.title = text\n q.parts = q.parts.map((p) => ({ ...p, name: p.field.label || p.field.key }))\n }\n for (const { fields: list, title } of stacks)\n out.push({\n id: list[0].id,\n title,\n page: list[0].position.page,\n parts: list.map((field) => ({ field, name: field.label })),\n rows: [list.length],\n block: true,\n })\n const at = (q: Question) => top(q.parts.map((p) => p.field).filter((f) => f.position.page === q.page))\n out.sort((a, b) => a.page - b.page || at(a) - at(b))\n setCategories(out, sorted, textOf)\n setSections(out, sorted, textOf)\n setScales(out, textOf)\n return mergeBlocks(out)\n}\n\n// The text written right of boxes on their line (their own names aside), and its lines wrapped under it, until\n// the next box's line or a gap. A wrapped line may start in the boxes' column (\"(tick as appropriate) certificates\n// and…\" read as one line across both columns): the part left of the text's column is cut off, at a word break.\nfunction sideText(lines: TextLine[], boxes: FieldDef[], fields: FieldDef[]) {\n const [y0, y1, x1] = [top(boxes), bottom(boxes), right(boxes)]\n const own = new Set(boxes.map((f) => norm(f.label)))\n const first = lines\n .filter((l) => l.x >= x1 - 0.002 && l.y + l.height / 2 > y0 && l.y + l.height / 2 < y1 && !own.has(norm(l.text)))\n .sort((a, b) => a.x - b.x)[0]\n if (!first) return ''\n const x = first.x\n const others = fields.filter((f) => !boxes.includes(f))\n const out = [first.text.trim()]\n for (let cur = first, k = 0; k < 5; k++) {\n const c = cur\n const next = lines.find(\n (l) =>\n l.y > c.y + c.height / 2 &&\n l.y - (c.y + c.height) < 0.6 * c.height &&\n l.x < x + 0.02 &&\n l.x + l.width > x + 0.02 &&\n !others.some((f) => f.position.y < l.y + l.height && f.position.y + f.position.height > l.y),\n )\n if (!next) break\n let t = next.text.trim()\n if (next.x < x - 0.01) {\n // Cut at the word break nearest to where the column starts, by the share of the line's width.\n const at = Math.round(((x - next.x) / next.width) * t.length)\n const breaks = [...t.matchAll(/ /g)].map((m) => m.index!)\n if (!breaks.length) break\n t = t.slice(breaks.reduce((a, b) => (Math.abs(b - at) < Math.abs(a - at) ? b : a)) + 1)\n }\n out.push(t)\n cur = next\n }\n return out.join(' ').replace(/\\s+/g, ' ')\n}\n\nconst BIG: FieldType[] = ['textarea', 'signature', 'file']\n\n// Boxes one under another at the same x, each the only box on its line and named by its own label, with text left of\n// them: the options of the question written there. Without text beside them they are a list of options under a\n// heading (handled as an option grid).\nfunction findStacks(sorted: FieldDef[], textOf: PageText) {\n const out: { fields: FieldDef[]; title: string }[] = []\n const pages = new Map<number, FieldDef[]>()\n for (const f of sorted) pages.set(f.position.page, [...(pages.get(f.position.page) ?? []), f])\n for (const [page, fs] of pages) {\n const overlaps = (a: FieldDef, b: FieldDef) =>\n a.position.y < b.position.y + b.position.height && a.position.y + a.position.height > b.position.y\n const isBox = (f: FieldDef) => f.type === 'checkbox' || f.type === 'radio'\n // Alone among boxes on its line (a row \"☐ Y ☐ N ☐ NA\" is not a column); a text box beside it may share it.\n const boxes = fs.filter((f) => isBox(f) && f.label.trim() && !fs.some((o) => o !== f && isBox(o) && overlaps(o, f)))\n const used = new Set<FieldDef>()\n for (const f of boxes) {\n if (used.has(f)) continue\n const stack = [f]\n for (const g of boxes) {\n const p = stack[stack.length - 1].position\n if (\n g.position.y > p.y &&\n Math.abs(g.position.x - f.position.x) < 0.006 &&\n g.position.y - (p.y + p.height) < p.height\n )\n stack.push(g)\n }\n if (stack.length < 2) continue\n const title = blockText(textOf(page) ?? [], stack, fs)\n if (!title) continue\n stack.forEach((g) => used.add(g))\n out.push({ fields: stack, title })\n }\n }\n return out\n}\n\n// The text block left of a column of boxes: the lines beside it, and the lines set tight above and below them,\n// up to a category code or numbered heading above and a big answer box (a \"Finding\" textarea) below. Lines\n// carried on in lower case join the line before; the others (\"A1.\", \"A2.\", \"● …\") start their own line.\nfunction blockText(lines: TextLine[], stack: FieldDef[], fields: FieldDef[]) {\n const x = left(stack)\n const [y0, y1] = [top(stack), bottom(stack)]\n const own = new Set(stack.map((f) => norm(f.label)))\n const big = fields.filter((f) => BIG.includes(f.type))\n const stop = (l: TextLine) =>\n (CODE.test(l.text.trim()) && !COLUMN.test(l.text.trim())) ||\n NUMBERED.test(l.text.trim()) ||\n !!tagsOf(l.text) ||\n big.some((f) => f.position.y < l.y + l.height && f.position.y + f.position.height > l.y)\n const col = lines.filter((l) => l.x + l.width <= x + 0.005 && !own.has(norm(l.text))).sort((a, b) => a.y - b.y)\n const beside = (l: TextLine) => l.y + l.height / 2 > y0 && l.y + l.height / 2 < y1\n let i = col.findIndex(beside)\n let j = col.length - 1 - [...col].reverse().findIndex(beside)\n if (i < 0) return ''\n const tight = (a: TextLine, b: TextLine) => b.y - (a.y + a.height) < 1.2 * Math.max(a.height, b.height)\n // The first box may sit level with the category code (\"HF\" │ ☐ Excd. Expectation): the text starts under it.\n for (let k = j; k >= i; k--)\n if (stop(col[k])) {\n if (k === j && (k + 1 >= col.length || !tight(col[k], col[k + 1]) || stop(col[k + 1]))) return ''\n i = k + 1\n j = Math.max(j, i)\n break\n }\n while (i > 0 && !stop(col[i - 1]) && tight(col[i - 1], col[i])) i--\n while (j < col.length - 1 && !stop(col[j + 1]) && tight(col[j], col[j + 1])) j++\n return col\n .slice(i, j + 1)\n .map((l) => l.text.trim())\n .reduce((a, t) => (!a ? t : /^\\p{Ll}/u.test(t) ? `${a} ${t}` : `${a}\\n${t}`), '')\n}\n\n// With categories, a question's parts under one category heading of one numbered question are one card: the\n// column of options with its text, then the \"Finding\" box under it. Two option grids are joined only across a\n// page break (the options of one column split over two pages), into one row.\nfunction mergeBlocks(out: Question[]) {\n if (!out.some((q) => q.category)) return out\n const size = (q: Question) => q.rows?.reduce((x, y) => x + y, 0) ?? 0\n const res: Question[] = []\n for (const q of out) {\n const last = res[res.length - 1]\n const same =\n last &&\n q.category &&\n last.category === q.category &&\n last.section === q.section &&\n !q.columns &&\n !last.columns &&\n (!(q.rows && last.rows) || q.page !== last.page)\n if (!same) {\n res.push(q)\n continue\n }\n const grid = [...last.parts.slice(0, size(last)), ...q.parts.slice(0, size(q))]\n res[res.length - 1] = {\n ...last,\n title: q.block && !last.block ? q.title : last.title,\n parts: [...grid, ...last.parts.slice(size(last)), ...q.parts.slice(size(q))],\n rows: q.rows && last.rows ? [grid.length] : (last.rows ?? q.rows),\n block: last.block || q.block,\n }\n }\n return res\n}\n\n// A category code written as a heading of its own over blocks of questions (\"HW\" / \"PR\" / \"HF\").\nconst CODE = /^[A-Z]{2,4}$/\n\n// Codes first on their line, with no other code after them (a header row \"RANK NAME DATE\" is not), and not a\n// running header or footer (same text at the same height on 3+ pages). The form has categories when at\n// least two codes come back at least twice each; a question takes the last one above it, across page breaks.\nfunction setCategories(out: Question[], fields: FieldDef[], textOf: PageText) {\n const last = Math.max(0, ...fields.map((f) => f.position.page))\n const found: { page: number; y: number; code: string }[] = []\n const isCode = (t: string) => CODE.test(t.trim()) && !COLUMN.test(t.trim())\n for (let page = 1; page <= last; page++) {\n const lines = textOf(page) ?? []\n for (const l of lines) {\n if (!isCode(l.text)) continue\n const beside = lines.filter((o) => o !== l && o.y < l.y + l.height && o.y + o.height > l.y)\n if (beside.some((o) => o.x < l.x || isCode(o.text))) continue\n found.push({ page, y: l.y, code: l.text.trim() })\n }\n }\n const marks = found.filter(\n (m) => found.filter((o) => o.page !== m.page && o.code === m.code && Math.abs(o.y - m.y) < 0.003).length < 2,\n )\n const times = new Map<string, number>()\n for (const m of marks) times.set(m.code, (times.get(m.code) ?? 0) + 1)\n if ([...times.values()].filter((t) => t >= 2).length < 2) return\n for (const q of out) q.category = lastAbove(q, marks)?.code\n}\n\n// The last mark (in document order) above a question, by its middle: a cell's answer boxes are centred in it,\n// the first one may start above the heading.\nfunction lastAbove<T extends { page: number; y: number }>(q: Question, marks: T[]) {\n const own = q.parts.map((p) => p.field).filter((f) => f.position.page === q.page)\n const y = (top(own) + bottom(own)) / 2\n return marks.filter((m) => m.page < q.page || (m.page === q.page && m.y <= y)).at(-1)\n}\n\n// A numbered heading: \"2.1.1 Maintenance of statutory Certificates\" (two levels at least: \"1. Vessel free…\" is an\n// item inside a question; \"5.9.2 / 5.10.2\" is an index).\nconst NUMBERED = /^\\d+(\\.\\d+)+\\.?\\s+\\p{L}/u\n\n// Numbered headings first on their line, with no field on it (a line with fields is a question itself): each\n// question shows the heading it answers, carried over page breaks.\nfunction setSections(out: Question[], fields: FieldDef[], textOf: PageText) {\n const last = Math.max(0, ...fields.map((f) => f.position.page))\n const marks: { page: number; y: number; text: string }[] = []\n for (let page = 1; page <= last; page++) {\n const lines = textOf(page) ?? []\n const boxes = fields.filter((f) => f.position.page === page)\n const edge = Math.max(0, ...lines.map((o) => o.x + o.width))\n for (const l of lines) {\n const text = l.text.trim().replace(/\\s+/g, ' ')\n if (!NUMBERED.test(text)) continue\n const across = (y: number, h: number) => y < l.y + l.height && y + h > l.y\n if (lines.some((o) => o !== l && o.x < l.x && across(o.y, o.height))) continue\n const free = (t: TextLine) =>\n !boxes.some((f) => f.position.y < t.y + t.height && f.position.y + f.position.height > t.y)\n if (!free(l)) continue\n // A heading wrapped onto the lines set tight under it: a line that ran to the text's right edge without\n // ending its sentence, or one the next line carries on in lower case (not a \"Note:\" after the heading).\n // Only lines in the heading's own text size: a picture's caption or watermark (\"Sampl\") is not part of it.\n // A heading cut by a picture or a box (\"…was it free of\" [photo] \"any areas for concern?\") carries on in the\n // first line of its size at its x under it, when that line starts in lower case and no field comes between.\n const parts = [text]\n const same = (o: TextLine) => Math.abs(o.height - l.height) < 0.3 * l.height\n for (let cur = l, k = 0; k < 4; k++) {\n const c = cur\n if (/[?.:!)]$/.test(c.text.trim())) break\n let next = lines.find(\n (o) =>\n o.y > c.y &&\n o.y - (c.y + c.height) < 0.5 * c.height &&\n o.x >= l.x - 0.01 &&\n o.x < l.x + l.width &&\n same(o) &&\n !NUMBERED.test(o.text.trim()) &&\n free(o),\n )\n if (next && !/^\\p{Ll}/u.test(next.text.trim()) && c.x + c.width < l.x + 0.85 * (edge - l.x)) break\n if (!next) {\n const below = lines\n .filter((o) => o.y > c.y + c.height && Math.abs(o.x - l.x) < 0.01 && same(o))\n .sort((a, b) => a.y - b.y)[0]\n const between = boxes.some((f) => f.position.y > c.y && f.position.y < (below?.y ?? 0))\n if (below && /^\\p{Ll}/u.test(below.text.trim()) && free(below) && !between) next = below\n }\n if (!next) break\n parts.push(next.text.trim())\n cur = next\n }\n marks.push({ page, y: l.y, text: parts.join(' ').replace(/\\s+/g, ' ') })\n }\n }\n // A numbered question's tags line (anywhere under its heading) applies to all of its questions. Only keys that\n // come back in two numbered questions or more are kept: a one-off \"Name: …, Date: …\" line is not a filter.\n const tagged = new Map<(typeof marks)[number], Record<string, string>>()\n for (let page = 1; page <= last; page++)\n for (const l of textOf(page) ?? []) {\n const tags = tagsOf(l.text)\n const mark = tags && marks.filter((m) => m.page < page || (m.page === page && m.y < l.y)).at(-1)\n if (mark) tagged.set(mark, { ...tagged.get(mark), ...tags })\n }\n const seen = new Map<string, number>()\n for (const t of tagged.values()) for (const k of Object.keys(t)) seen.set(k, (seen.get(k) ?? 0) + 1)\n for (const q of out) {\n const mark = lastAbove(q, marks)\n q.section = mark?.text\n if (q.section && !q.rows && !q.columns) pieceTitle(q, q.section, textOf(q.page) ?? [])\n const tags = Object.entries((mark && tagged.get(mark)) ?? {}).filter(([k]) => seen.get(k)! >= 2)\n if (tags.length) q.tags = Object.fromEntries(tags)\n }\n}\n\n// Letters that look the same in other scripts (\"Ν/S\" typed with a Greek Ν), so a choice matches its legend.\nconst LOOKALIKE: Record<string, string> = {\n Α: 'A',\n Β: 'B',\n Ε: 'E',\n Η: 'H',\n Ι: 'I',\n Κ: 'K',\n Μ: 'M',\n Ν: 'N',\n Ο: 'O',\n Ρ: 'P',\n Τ: 'T',\n Υ: 'Y',\n Χ: 'X',\n Ζ: 'Z',\n}\nconst fold = (t: string) =>\n t\n .replace(/[ΑΒΕΗΙΚΜΝΟΡΤΥΧΖ]/g, (c) => LOOKALIKE[c])\n .trim()\n .toLowerCase()\nconst escape = (t: string) => t.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')\n\n// Rows answered on the same scale, one under another (\"Chapter 02 … ☐1 ☐2 ☐3 ☐4 ☐5 ☐N/S\"): the text set tight\n// above the first row says what they ask (its column names aside), and a legend on the page (\"5 “excellent”\n// means…\", \"1 = Poor\") says what each choice means, written after the choice's name (\"5 – excellent\").\nfunction setScales(out: Question[], textOf: PageText) {\n for (let i = 0; i < out.length;) {\n const choices = choicesOf(out[i]).map(fold)\n let j = i + 1\n while (\n choices.length >= 2 &&\n j < out.length &&\n out[j].page === out[i].page &&\n choicesOf(out[j]).map(fold).join('|') === choices.join('|')\n )\n j++\n if (j - i >= 2) scale(out.slice(i, j), choices, textOf)\n i = j\n }\n}\n\nfunction scale(run: Question[], choices: string[], textOf: PageText) {\n const first = run[0]\n const lines = textOf(first.page) ?? []\n const y0 = top(first.parts.map((p) => p.field))\n const isHeader = (l: TextLine) => l.text.split(/\\s+/).every((w) => choices.includes(fold(w)))\n const above = lines.filter((l) => l.y + l.height <= y0 + 0.002 && !isHeader(l)).sort((a, b) => b.y - a.y)\n const block: TextLine[] = []\n for (const l of above) {\n const below = block[block.length - 1]\n const gap = (below ? below.y : y0) - (l.y + l.height)\n if (gap > 1.2 * l.height || block.length === 3) break\n block.push(l)\n }\n const intro = block\n .reverse()\n .map((l) => l.text.trim())\n .join(' ')\n if (intro && !run.some((q) => norm(q.title) === norm(intro))) for (const q of run) q.intro = intro\n\n // The legend: lines that start with a choice's name, on this page or the next.\n const legend = new Map<string, string>()\n for (const l of [...lines, ...(textOf(first.page + 1) ?? [])]) {\n // A bullet before it may be a symbol font's private-use glyph (U+F0B7).\n const t = l.text.replace(/^[\\s\\p{Co}•●▪■◦*–-]+/u, '').trim()\n for (const c of choices) {\n if (legend.has(c)) continue\n const m = new RegExp(`^${escape(c)}\\\\s*(?:[=:–-]\\\\s*|\\\\s)(\\\\S.*)$`, 'i').exec(fold(t))\n if (!m) continue\n const rest = t.slice(t.length - m[1].length)\n const said =\n /[“\"‘]([^”\"’]{1,40})[”\"’]/.exec(rest)?.[1] ??\n rest\n .split(/\\s+means\\s+/i)[0]\n .split(/\\s+/)\n .slice(0, 6)\n .join(' ')\n legend.set(c, said.replace(/[.,;:]$/, ''))\n }\n }\n if (legend.size < Math.max(2, Math.ceil(0.6 * choices.length))) return\n for (const q of run)\n q.parts = q.parts.map((p) => {\n const said = legend.get(fold(p.name))\n return p.field.type === 'checkbox' && said ? { ...p, name: `${p.name} – ${said}` } : p\n })\n}\n\n// A card titled by a piece of its numbered question (\"any areas for concern?\", or the question cut short in a\n// label): the question is in the heading over it, so the card is named by what its box is, the \"Comments:\" written\n// on the box's line, else its part's name.\nfunction pieceTitle(q: Question, section: string, lines: TextLine[]) {\n const title = norm(q.title).replace(/[…]+$/, '')\n if (!title || !norm(section).includes(title)) return\n const f = q.parts[0].field\n const label = lines.find(\n (l) =>\n /^[^:]{1,30}:$/.test(l.text.trim()) && l.y < f.position.y + f.position.height && l.y + l.height > f.position.y,\n )\n const name = label?.text.trim().slice(0, -1) || (q.parts.length === 1 ? q.parts[0].name : '')\n if (!name) return\n q.title = name\n if (q.parts.length === 1 && norm(q.parts[0].name) === norm(name)) q.parts = [{ ...q.parts[0], name: '' }]\n}\n\n// \"Key: Value , Key: Value\": two pairs at least, short keys (up to 3 words) and values.\nconst PAIR = /^\\s*(\\p{L}[\\p{L} ]{0,24}?)\\s*:\\s*([^,:]{1,40}?)\\s*$/u\nfunction tagsOf(text: string) {\n const parts = text.split(',')\n if (parts.length < 2) return null\n const pairs = parts.map((p) => PAIR.exec(p))\n if (pairs.some((m) => !m || m[1].trim().split(/\\s+/).length > 3)) return null\n return Object.fromEntries(pairs.map((m) => [m![1].trim(), m![2].trim()]))\n}\n\nexport type Filled = 'all' | 'filled' | 'empty'\nexport interface QuestionFilter {\n search: string\n answered: Filled\n remarks: Filled\n comments: Filled\n type: FieldType | 'all'\n category: string\n /** Tag key → value (\"Risk\" → \"H\"). */\n tags: Record<string, string>\n /** Questions that offer this answer choice (\"Yes\", \"As Expected\"). */\n choices: string\n /** Questions answered with this choice (\"Not as Expected\"). */\n picked: string\n}\nexport const NO_FILTER: QuestionFilter = {\n search: '',\n answered: 'all',\n remarks: 'all',\n comments: 'all',\n type: 'all',\n category: 'all',\n tags: {},\n choices: 'all',\n picked: 'all',\n}\n\nexport const isFilled = (v: AnswerValue | undefined) => v != null && v !== '' && v !== false\n\n// \"Remarks\" / \"Comments\" parts, by their column name or label.\nconst partsLike = (q: Question, re: RegExp) => q.parts.filter((p) => re.test(p.name) || re.test(p.field.label))\n\nfunction check(parts: Question['parts'], answers: Answers, want: Filled) {\n if (want === 'all') return true\n if (!parts.length) return false // filtering on remarks: questions without a remarks box are not shown\n const any = parts.some((p) => isFilled(answers[p.field.key]))\n return want === 'filled' ? any : !any\n}\n\n/** Whether a question passes every filter. */\n// The boxes that are a question's answer choices: its option grid, else its boxes (\"Y / N / NA\" on one line, or\n// a lone \"☐ Human\").\nfunction choiceParts(q: Question) {\n if (q.columns) return []\n const size = q.rows?.reduce((a, b) => a + b, 0) ?? 0\n const boxes = (q.rows ? q.parts.slice(0, size) : q.parts).filter((p) => p.field.type === 'checkbox')\n return boxes\n}\nconst radioOf = (q: Question) => q.parts.find((p) => p.field.type === 'radio' && p.field.options?.length)?.field\n\n/** A question's answer choices (\"Yes\", \"No\", \"NA\"), from its boxes or its radio buttons. */\nexport function choicesOf(q: Question): string[] {\n const boxes = choiceParts(q)\n if (boxes.length) return boxes.map((p) => (p.name || p.field.label).trim())\n return radioOf(q)?.options ?? []\n}\n\n/** The choices ticked in a question's answers. */\nexport function pickedOf(q: Question, answers: Answers): string[] {\n const boxes = choiceParts(q)\n if (boxes.length)\n return boxes.filter((p) => answers[p.field.key] === true).map((p) => (p.name || p.field.label).trim())\n const radio = radioOf(q)\n const v = radio && answers[radio.key]\n return typeof v === 'string' && v ? [v] : []\n}\n\nexport function matches(q: Question, answers: Answers, f: QuestionFilter) {\n const text = f.search.trim().toLowerCase()\n if (\n text &&\n !`${q.section ?? ''} ${q.intro ?? ''} ${q.title} ${q.parts.map((p) => p.field.label).join(' ')}`\n .toLowerCase()\n .includes(text)\n )\n return false\n if (f.type !== 'all' && !q.parts.some((p) => p.field.type === f.type)) return false\n if (f.category !== 'all' && q.category !== f.category) return false\n if (Object.entries(f.tags).some(([k, v]) => q.tags?.[k] !== v)) return false\n if (f.choices !== 'all' && !choicesOf(q).includes(f.choices)) return false\n if (f.picked !== 'all' && !pickedOf(q, answers).includes(f.picked)) return false\n return (\n check(q.parts, answers, f.answered) &&\n check(partsLike(q, /remark/i), answers, f.remarks) &&\n check(partsLike(q, /comment/i), answers, f.comments)\n )\n}\n"]}