import {
  describe, test, expect, beforeAll, afterAll,
} from 'vitest';
import scribe from '../../scribe.js';
import { subsetPdf } from '../../js/export/pdf/subsetPdf.js';
import { mergePdfs } from '../../js/export/pdf/mergePdfs.js';
import { ASSETS_PATH, LANG_PATH } from './_paths.js';

const isNode = typeof process !== 'undefined' && process.versions && process.versions.node;

/** @param {string} pdfPath */
async function readPdfBytes(pdfPath) {
  if (isNode) {
    const { readFile } = await import('node:fs/promises');
    return new Uint8Array(await readFile(pdfPath));
  }
  const response = await fetch(pdfPath);
  return new Uint8Array(await response.arrayBuffer());
}

scribe.opt.workerN = 1;
scribe.opt.langPath = LANG_PATH;

// Using arrow functions breaks references to `this`.

describe('Check export for .pdf files.', () => {
  test('Export -> import of simple text-only ebook-style PDF retains text content', async () => {
    await scribe.importFiles([`${ASSETS_PATH}/text_simple.txt`]);

    const exportedText = await scribe.exportData('text');

    // Inject an empty-text word to verify empty words do not cause errors during PDF export.
    // See: https://github.com/scribeocr/scribeocr/issues/91
    const line0 = scribe.data.ocr.active[0].lines[0];
    line0.words.push(new scribe.utils.ocr.OcrWord(line0, 'empty_word_test', '', {
      left: 100, top: 100, right: 100, bottom: 120,
    }));

    const exportedPdf = await scribe.exportData('pdf');

    scribe.opt.displayMode = 'ebook';

    await scribe.clear();
    scribe.opt.usePDFText.native.main = true;
    scribe.opt.keepPDFTextAlways = true;

    await scribe.importFiles({ pdfFiles: [exportedPdf] });

    scribe.data.ocr.active = scribe.data.ocr.pdf;
    const reExportedText = await scribe.exportData('text');

    expect(reExportedText).toBe(exportedText);
    expect(reExportedText).toBe('Tesseract.js');
    await scribe.clear();
  });

  test('Export -> import of image + text (visible, proofreading) PDF retains text content', async () => {
    scribe.opt.displayMode = 'proof';

    await scribe.importFiles([`${ASSETS_PATH}/testocr.png`, `${ASSETS_PATH}/testocr.abbyy.xml`]);

    const exportedPdf = await scribe.exportData('pdf');
    const exportedText = await scribe.exportData('text');

    // Inject an empty-text word to verify empty words do not cause errors during HTML export.
    // See: https://github.com/scribeocr/scribeocr/issues/91
    const line0html = scribe.data.ocr.active[0].lines[0];
    line0html.words.push(new scribe.utils.ocr.OcrWord(line0html, 'empty_word_html_test', '', {
      left: 100, top: 100, right: 100, bottom: 120,
    }));

    const exportedHtml = await scribe.exportData('html');
    expect(exportedHtml).toContain('>point<');

    await scribe.clear();
    scribe.opt.usePDFText.native.main = true;

    await scribe.importFiles({ pdfFiles: [exportedPdf] });

    scribe.data.ocr.active = scribe.data.ocr.pdf;
    const reExportedText = await scribe.exportData('text');

    expect(reExportedText).toBe(exportedText);
    expect(reExportedText).toContain('This is a lot of 12 point text');
  });

  for (const mode of /** @type {const} */ (['invis', 'proof'])) {
    test(`Existing invisible OCR layer is stripped before overlaying (displayMode='${mode}')`, async () => {
      scribe.opt.usePDFText.ocr.main = true;
      await scribe.importFiles([`${ASSETS_PATH}/scribe_test_pdf1.pdf`]);
      expect(scribe.inputData.pdfType).toBe('ocr');

      scribe.opt.displayMode = mode;
      const exportedPdf = await scribe.exportData('pdf');

      await scribe.clear();
      scribe.opt.usePDFText.ocr.main = true;
      scribe.opt.keepPDFTextAlways = true;
      await scribe.importFiles({ pdfFiles: [exportedPdf] });
      const ocr = scribe.data.ocr.active.length ? scribe.data.ocr.active : scribe.data.ocr.pdf;

      let lines = 0;
      let words = 0;
      const colors = new Set();
      const opacities = new Set();
      for (const page of ocr) {
        lines += page.lines.length;
        for (const line of page.lines) {
          for (const w of line.words) {
            words++;
            colors.add(w.style.color);
            opacities.add(w.style.opacity);
          }
        }
      }
      expect(lines).toBe(58);
      expect(words).toBe(421);
      expect(colors.size).toBe(1);
      expect(opacities.size).toBe(1);
      if (mode === 'invis') {
        expect([...colors]).toEqual(['#000000']);
        expect([...opacities]).toEqual([0]);
      } else {
        // Confidence buckets in the source PDF text default to high → green
        // (#00ff80) at proofOpacity 0.8 since no recognise pass was run.
        expect([...colors]).toEqual(['#00ff80']);
        expect([...opacities]).toEqual([0.8]);
      }

      scribe.opt.displayMode = 'proof';
      scribe.opt.usePDFText.ocr.main = false;
      scribe.opt.keepPDFTextAlways = false;
      await scribe.clear();
    });
  }

  test('Export of text-native PDF preserves visible text when adding overlay', async () => {
    scribe.opt.usePDFText.native.main = true;
    await scribe.importFiles([`${ASSETS_PATH}/small_caps_examples.pdf`]);

    expect(scribe.inputData.pdfType).toBe('text');

    // Delete the active OCR data before export so no invisible text overlay is added.
    // Without this, the native text extracted on import would be written back as an
    // invisible overlay, creating the same duplication issue tested above.
    scribe.data.ocr.active.length = 0;

    scribe.opt.displayMode = 'invis';
    const exportedPdf = await scribe.exportData('pdf');

    await scribe.clear();
    scribe.opt.usePDFText.native.main = true;
    await scribe.importFiles({ pdfFiles: [exportedPdf] });

    expect(scribe.inputData.pdfType).toBe('text');

    const text = scribe.data.ocr.active[0].lines[3].words.map((x) => x.text).join(' ');
    expect(text).toBe('Shubhdeep Deb');

    scribe.opt.displayMode = 'proof';
    await scribe.clear();
  });

  test('Annotations container is initialized for each page on import', async () => {
    await scribe.importFiles([`${ASSETS_PATH}/testocr.png`, `${ASSETS_PATH}/testocr.abbyy.xml`]);

    expect(Array.isArray(scribe.data.annotations.pages[0])).toBe(true);
    expect(scribe.data.annotations.pages[0].length).toBe(0);
  });

  test('Highlight annotations are preserved through .scribe export and import', async () => {
    scribe.data.annotations.pages[0].push({
      bbox: {
        left: 100, top: 200, right: 300, bottom: 220,
      },
      color: '#ffff00',
      opacity: 0.35,
      groupId: 'test-export-1',
    });

    scribe.opt.compressScribe = false;
    const scribeData = await scribe.exportData('scribe');

    await scribe.clear();

    const encoder = new TextEncoder();
    await scribe.importFiles({ scribeFiles: [encoder.encode(scribeData).buffer] });

    expect(scribe.data.annotations.pages[0].length).toBe(1);
    expect(scribe.data.annotations.pages[0][0].color).toBe('#ffff00');
    expect(scribe.data.annotations.pages[0][0].opacity).toBe(0.35);
    expect(scribe.data.annotations.pages[0][0].bbox.left).toBe(100);
    expect(scribe.data.annotations.pages[0][0].bbox.right).toBe(300);

    scribe.opt.compressScribe = true;
    await scribe.clear();
  });

  test('Highlight annotations are preserved through PDF export and re-import', async () => {
    await scribe.importFiles([`${ASSETS_PATH}/complaint_1.pdf`, `${ASSETS_PATH}/complaint_1.abbyy.xml`]);
    scribe.addHighlights([{ page: 0, startLine: 0, endLine: 2 }]);
    // addHighlights emits one entry per word; lines 0-2 of complaint_1.abbyy.xml have 41 words.
    expect(scribe.data.annotations.pages[0].length).toBe(41);

    const pdfBytes = await scribe.exportData('pdf');
    await scribe.clear();

    await scribe.importFiles({ pdfFiles: [new Uint8Array(pdfBytes).buffer] });
    const highlights = scribe.data.annotations.pages.flatMap((p) => p || []);
    // Export consolidates the 29 per-word highlights into a single multi-quad
    // annotation spanning lines 0-2.
    expect(highlights.length).toBe(1);
    expect(highlights[0].quads.length).toBe(3);
    expect(highlights[0].color).toBe('#ffe93b');
    expect(highlights[0].opacity).toBe(0.4);
    await scribe.clear();
  });

  test('Exported PDF is compressed (FlateDecode) by default and larger under humanReadablePDF', async () => {
    // Regression gate for the compression + font-subsetting pipeline. The
    // compressed output on testocr.png + .abbyy.xml was ~220 KB before
    // FlateDecode + Latin-font subsetting landed; it is ~57 KB after. The
    // `humanReadablePDF` branch preserves pre-compression diffing by
    // emitting ASCII-hex streams, which bloats the output back up.
    scribe.opt.displayMode = 'proof';
    await scribe.importFiles([`${ASSETS_PATH}/testocr.png`, `${ASSETS_PATH}/testocr.abbyy.xml`]);

    scribe.opt.humanReadablePDF = false;
    const compressed = await scribe.exportData('pdf');
    expect(compressed.byteLength).toBeLessThan(70000);

    scribe.opt.humanReadablePDF = true;
    const humanReadable = await scribe.exportData('pdf');
    expect(humanReadable.byteLength).toBeGreaterThan(compressed.byteLength);

    scribe.opt.humanReadablePDF = false;
    await scribe.clear();
  });

  describe('Proof-mode round-trip preserves color, opacity, and text', () => {
    /** @type {Array<{ color: string, opacity: number }>} */
    let compressedWords;
    /** @type {string} */
    let textCompressed;
    /** @type {string} */
    let textHuman;

    beforeAll(async () => {
      scribe.opt.displayMode = 'proof';
      await scribe.importFiles([`${ASSETS_PATH}/testocr.png`, `${ASSETS_PATH}/testocr.abbyy.xml`]);

      scribe.opt.humanReadablePDF = false;
      const pdfCompressed = await scribe.exportData('pdf');
      scribe.opt.humanReadablePDF = true;
      const pdfHuman = await scribe.exportData('pdf');
      scribe.opt.humanReadablePDF = false;

      await scribe.clear();
      scribe.opt.usePDFText.native.main = true;
      await scribe.importFiles({ pdfFiles: [pdfCompressed] });
      scribe.data.ocr.active = scribe.data.ocr.pdf;
      compressedWords = [];
      for (const line of scribe.data.ocr.active[0].lines) {
        for (const w of line.words) compressedWords.push({ color: w.style.color, opacity: w.style.opacity });
      }
      textCompressed = /** @type {string} */ (await scribe.exportData('text'));

      await scribe.clear();
      scribe.opt.usePDFText.native.main = true;
      await scribe.importFiles({ pdfFiles: [pdfHuman] });
      scribe.data.ocr.active = scribe.data.ocr.pdf;
      textHuman = /** @type {string} */ (await scribe.exportData('text'));
    });

    afterAll(async () => {
      await scribe.clear();
    });

    test('Compressed export round-trips 59 high-confidence words coloured green (#00ff80)', () => {
      const greenCount = compressedWords.filter((w) => w.color === '#00ff80').length;
      expect(greenCount).toBe(59);
    });

    test('Compressed export round-trips 1 low-confidence word coloured red (#ff0000)', () => {
      const redCount = compressedWords.filter((w) => w.color === '#ff0000').length;
      expect(redCount).toBe(1);
    });

    test('Compressed export emits exactly the two confidence colours present in this fixture (no medium-confidence words)', () => {
      const distinctColors = new Set(compressedWords.map((w) => w.color));
      expect([...distinctColors].sort()).toEqual(['#00ff80', '#ff0000']);
    });

    test('Compressed export round-trips proofOpacity 0.8 for every word', () => {
      const distinctOpacities = new Set(compressedWords.map((w) => w.opacity));
      expect([...distinctOpacities]).toEqual([0.8]);
    });

    test('humanReadable export yields identical extracted text to compressed export', () => {
      expect(textHuman).toBe(textCompressed);
    });

    test('Round-tripped text contains the expected source content', () => {
      expect(textCompressed).toContain('This is a lot of 12 point text');
    });
  });

  test('PDF overlay with page subset exports only the requested pages and removes unreferenced objects', async () => {
    scribe.opt.usePDFText.native.main = true;
    scribe.opt.keepPDFTextAlways = true;
    await scribe.importFiles([`${ASSETS_PATH}/Iris (plant) - Wikipedia_123.pdf`]);

    expect(scribe.data.ocr.active.length).toBe(3);
    expect(scribe.data.ocr.active[0].lines[0].words[0].text).toBe('Iris');

    scribe.opt.displayMode = 'invis';
    scribe.opt.addOverlay = true;
    const fullExportPdf = /** @type {ArrayBuffer} */ (await scribe.exportData('pdf'));
    const fullExportSize = fullExportPdf.byteLength;

    const exportedPdf = /** @type {ArrayBuffer} */ (await scribe.exportData('pdf', { minPage: 1, maxPage: 2 }));
    expect(exportedPdf.byteLength).toBeGreaterThan(1000);
    expect(exportedPdf.byteLength).toBeLessThan(fullExportSize);

    await scribe.clear();
    scribe.opt.usePDFText.native.main = true;
    scribe.opt.keepPDFTextAlways = true;
    await scribe.importFiles({ pdfFiles: [exportedPdf] });

    scribe.data.ocr.active = scribe.data.ocr.pdf;
    expect(scribe.data.ocr.active.length).toBe(2);

    for (let i = 0; i < 2; i++) {
      const pageText = /** @type {string} */ (await scribe.exportData('text', { minPage: i, maxPage: i }));
      expect(pageText.length).toBeGreaterThan(10);
    }

    const exportedPage0Text = /** @type {string} */ (await scribe.exportData('text', { minPage: 0, maxPage: 0 }));
    expect(exportedPage0Text).not.toContain('Iris (plant)');

    scribe.opt.displayMode = 'proof';
    await scribe.clear();
  });

  test('subsetPdf keeps arbitrary pages and drops unreferenced resource objects', async () => {
    const originalBytes = (await readPdfBytes(`${ASSETS_PATH}/Iris (plant) - Wikipedia_123.pdf`)).buffer;

    // Case 1: keep pages 0 and 2, drop the middle page.
    const subsetBytes02 = /** @type {ArrayBuffer} */ (await subsetPdf(originalBytes, [0, 2]));
    expect(subsetBytes02.byteLength).toBeGreaterThan(1000);
    expect(subsetBytes02.byteLength).toBeLessThan(originalBytes.byteLength);

    await scribe.clear();
    scribe.opt.usePDFText.native.main = true;
    scribe.opt.keepPDFTextAlways = true;
    await scribe.importFiles({ pdfFiles: [subsetBytes02] });
    scribe.data.ocr.active = scribe.data.ocr.pdf;

    expect(scribe.data.ocr.active.length).toBe(2);
    expect(scribe.data.ocr.active[0].lines[0].words[0].text).toBe('Iris');
    const subsetPage1Text = /** @type {string} */ (await scribe.exportData('text', { minPage: 1, maxPage: 1 }));
    expect(subsetPage1Text).not.toContain('Iris (plant)');

    // Case 2: keep just the middle page.
    await scribe.clear();
    const subsetBytes1 = /** @type {ArrayBuffer} */ (await subsetPdf(originalBytes, [1]));
    expect(subsetBytes1.byteLength).toBeGreaterThan(1000);

    scribe.opt.usePDFText.native.main = true;
    scribe.opt.keepPDFTextAlways = true;
    await scribe.importFiles({ pdfFiles: [subsetBytes1] });
    scribe.data.ocr.active = scribe.data.ocr.pdf;

    expect(scribe.data.ocr.active.length).toBe(1);
    const middlePageText = /** @type {string} */ (await scribe.exportData('text', { minPage: 0, maxPage: 0 }));
    expect(middlePageText).not.toContain('Iris (plant)');

    await scribe.clear();
  });

  test('mergePdfs concatenates pages from two input PDFs into one output', async () => {
    const originalBytes = (await readPdfBytes(`${ASSETS_PATH}/Iris (plant) - Wikipedia_123.pdf`)).buffer;

    const mergedBytes = /** @type {ArrayBuffer} */ (await mergePdfs([originalBytes, originalBytes]));
    expect(mergedBytes.byteLength).toBeGreaterThan(1000);
    // Output should be roughly 2× the original.
    expect(mergedBytes.byteLength).toBeGreaterThan(originalBytes.byteLength * 1.5);
    expect(mergedBytes.byteLength).toBeLessThan(originalBytes.byteLength * 2.5);

    await scribe.clear();
    scribe.opt.usePDFText.native.main = true;
    scribe.opt.keepPDFTextAlways = true;
    await scribe.importFiles({ pdfFiles: [mergedBytes] });
    scribe.data.ocr.active = scribe.data.ocr.pdf;

    expect(scribe.data.ocr.active.length).toBe(6);
    expect(scribe.data.ocr.active[0].lines[0].words[0].text).toBe('Iris');
    expect(scribe.data.ocr.active[3].lines[0].words[0].text).toBe('Iris');

    await scribe.clear();
  });

  test("PDF overlay with full page range and displayMode='annot' retains all pages, preserves text without duplication, and round-trips highlight annotations", async () => {
    scribe.opt.usePDFText.native.main = true;
    scribe.opt.keepPDFTextAlways = true;
    await scribe.importFiles([`${ASSETS_PATH}/Iris (plant) - Wikipedia_123.pdf`]);

    const countWords = (pages) => {
      let n = 0;
      for (const page of pages) {
        for (const line of page.lines) n += line.words.filter((w) => w.text).length;
      }
      return n;
    };
    expect(countWords(scribe.data.ocr.active), 'source PDF native word count baseline').toBe(1064);

    scribe.addHighlights([{ page: 0, startLine: 0, endLine: 0 }]);
    expect(scribe.data.annotations.pages[0].length, 'addHighlights emits one annotation for the one-word line').toBe(1);

    scribe.opt.displayMode = 'annot';
    scribe.opt.addOverlay = true;
    const exportedPdf = /** @type {ArrayBuffer} */ (await scribe.exportData('pdf'));
    expect(exportedPdf.byteLength, 'annot-mode overlay export produces a non-trivial PDF').toBeGreaterThan(1000);

    await scribe.clear();
    scribe.opt.usePDFText.native.main = true;
    scribe.opt.keepPDFTextAlways = true;
    await scribe.importFiles({ pdfFiles: [exportedPdf] });

    scribe.data.ocr.active = scribe.data.ocr.pdf;
    expect(scribe.data.ocr.active.length, 'all 3 source pages survive the overlay round-trip').toBe(3);
    expect(
      countWords(scribe.data.ocr.active),
      'annot mode must not emit a visible overlay text layer on top of the source text',
    ).toBe(1064);

    const page0Text = /** @type {string} */ (await scribe.exportData('text', { minPage: 0, maxPage: 0 }));
    expect(page0Text, 'source page-0 text is preserved through the overlay round-trip').toContain('Iris');

    const highlights = scribe.data.annotations.pages.flatMap((p) => p || []);
    expect(highlights.length, 'the highlight annotation round-trips through annot-mode export').toBe(1);

    scribe.opt.displayMode = 'proof';
    await scribe.clear();
  });

  test('Human-readable text-only PDF has uncompressed content streams and hex-encoded fonts', async () => {
    await scribe.importFiles([`${ASSETS_PATH}/text_simple.txt`]);

    scribe.opt.displayMode = 'ebook';
    scribe.opt.humanReadablePDF = true;
    const exportedPdf = /** @type {ArrayBuffer} */ (await scribe.exportData('pdf'));
    scribe.opt.humanReadablePDF = false;

    const pdfBytes = new Uint8Array(exportedPdf);
    const pdfText = new TextDecoder().decode(pdfBytes);

    // Content streams should contain readable PDF text operators (not compressed).
    expect(pdfText).toContain('BT');
    expect(pdfText).toContain('Tf');
    expect(pdfText).not.toContain('/Filter/FlateDecode');
    // Font streams should use ASCIIHexDecode.
    expect(pdfText).toContain('/Filter/ASCIIHexDecode');

    // Skip the first 30 bytes (well past the header + marker comment) when asserting
    // the rest of the file is human-readable ASCII.
    let allAsciiBody = true;
    for (let i = 30; i < pdfBytes.length; i++) {
      if (pdfBytes[i] > 127) { allAsciiBody = false; break; }
    }
    expect(allAsciiBody).toBe(true);

    await scribe.clear();
    scribe.opt.usePDFText.native.main = true;
    scribe.opt.keepPDFTextAlways = true;
    await scribe.importFiles({ pdfFiles: [exportedPdf] });
    scribe.data.ocr.active = scribe.data.ocr.pdf;
    const reExportedText = await scribe.exportData('text');
    expect(reExportedText).toBe('Tesseract.js');

    scribe.opt.displayMode = 'proof';
    await scribe.clear();
  });

  test('PDF export of encrypted source with proof overlay produces a valid (decrypted) PDF', async () => {
    scribe.opt.usePDFText.native.main = true;
    scribe.opt.keepPDFTextAlways = true;
    await scribe.importFiles([`${ASSETS_PATH}/intel-history-1996-annual-report.pdf`]);

    expect(scribe.inputData.pageCount).toBe(22);
    expect(scribe.inputData.pdfType).toBe('text');

    scribe.opt.displayMode = 'proof';
    scribe.opt.addOverlay = true;
    const exportedPdf = /** @type {ArrayBuffer} */ (await scribe.exportData('pdf'));
    expect(exportedPdf.byteLength).toBeGreaterThan(1000);

    // The output must not retain a reference to the source PDF's /Encrypt dict.
    // When /Encrypt stays in the trailer chain, PDF readers RC4-decrypt the
    // unencrypted overlay objects with the file key, garbling them and breaking
    // every page (zlib "incorrect header check" on every FlateDecode stream).
    const exportedText = new TextDecoder('latin1').decode(new Uint8Array(exportedPdf));
    expect(exportedText.includes('/Encrypt')).toBe(false);

    // Acrobat is stricter than Chrome/Firefox about font dict syntax: PDF arrays use
    // whitespace as separators, never commas, and the CIDFont subtype must match the
    // embedded font program (CIDFontType0 for OpenType-CFF "OTTO", CIDFontType2 for
    // OpenType-TrueType). Either mismatch will silently drop the overlay glyphs in
    // Acrobat while browsers still render them.
    expect(exportedText.includes('FontBBox[0, 0, 0, 0]')).toBe(false);
    expect(exportedText.includes('/Subtype/CIDFontType2')).toBe(false);
    expect(exportedText.includes('/Subtype/CIDFontType0')).toBe(true);

    // Every FlateDecode stream copied from the encrypted source must inflate cleanly under a strict zlib reader.
    // Pre-fix, the EOL-strip heuristic in extractRawStreamBytes truncated the last data byte of
    // nine 1px image XObjects whose deflate stream happened to end on 0x0A/0x0D.
    // Use Node's inflateSync (strict) since scribe's own inflate path is tolerant of truncated streams and would mask the bug.
    // Node-only: the bug under test is in the writer (platform-independent), so checking in Node is sufficient.
    if (isNode) {
      const { findXrefOffset, parseXref, ObjectCache } = await import('../../js/pdf/parsePdfUtils.js');
      const { inflateSync } = await import('node:zlib');
      const exportBytes = new Uint8Array(exportedPdf);
      const xrefOffset = findXrefOffset(exportBytes);
      const xrefEntries = parseXref(exportBytes, xrefOffset);
      const objCache = new ObjectCache(exportBytes, xrefEntries);
      let inflatedCount = 0;
      const failures = [];
      for (const [k, entry] of Object.entries(xrefEntries)) {
        if (entry.type !== 1) continue;
        const objNum = Number(k);
        const objText = objCache.getObjectText(objNum);
        if (!objText || !/\/Filter\s*\/FlateDecode\b/.test(objText)) continue;
        const objStart = entry.offset;
        let p = objStart;
        while (p < exportBytes.length - 6 && !(
          exportBytes[p] === 0x73 && exportBytes[p + 1] === 0x74
          && exportBytes[p + 2] === 0x72 && exportBytes[p + 3] === 0x65
          && exportBytes[p + 4] === 0x61 && exportBytes[p + 5] === 0x6D
        )) p++;
        if (p >= exportBytes.length - 6) continue;
        let s = p + 6;
        if (exportBytes[s] === 0x0D && exportBytes[s + 1] === 0x0A) s += 2;
        else if (exportBytes[s] === 0x0A || exportBytes[s] === 0x0D) s += 1;
        const lengthMatch = /\/Length\s+(\d+)/.exec(objText);
        if (!lengthMatch) continue;
        const len = Number(lengthMatch[1]);
        const slice = exportBytes.subarray(s, s + len);
        try {
          inflateSync(Buffer.from(slice));
          inflatedCount++;
        } catch (e) {
          failures.push({ objNum, length: len, msg: e.message });
        }
      }
      expect(failures, `expected all FlateDecode streams to inflate; failures: ${JSON.stringify(failures.slice(0, 5))}`).toEqual([]);
      expect(inflatedCount).toBeGreaterThan(50);
    }

    await scribe.clear();
    scribe.opt.usePDFText.native.main = true;
    scribe.opt.keepPDFTextAlways = true;
    await scribe.importFiles({ pdfFiles: [exportedPdf] });

    expect(scribe.inputData.pageCount).toBe(22);
    const page7Words = scribe.data.ocr.pdf[7].lines.flatMap((l) => l.words.map((w) => w.text));
    expect(page7Words.slice(0, 5)).toEqual(['12', 'Intel', 'Corporation', '1996', 'www.intel.com']);

    scribe.opt.displayMode = 'proof';
    scribe.opt.usePDFText.native.main = false;
    scribe.opt.keepPDFTextAlways = false;
    await scribe.clear();
  });

  test('PDF overlay aligns with source text on pages where CropBox differs from MediaBox', async () => {
    // fti_filing_p25.pdf has CropBox [9 9 603 783] inside MediaBox [0 0 612 792]. The overlay
    // export must scale and offset relative to the CropBox (the visible region scribe rasterises
    // as 2475x3225 px), not the MediaBox — using MediaBox produces an overlay scaled ~1.03×
    // too large and translated to the MediaBox origin, so the proof-mode duplicates land off
    // the source text.
    scribe.opt.usePDFText.native.main = true;
    scribe.opt.keepPDFTextAlways = true;
    await scribe.importFiles([`${ASSETS_PATH}/fti_filing_p25.pdf`]);

    expect(scribe.inputData.pageCount).toBe(1);
    expect(scribe.data.ocr.active[0].lines[0].words[0].text).toBe('UNITED');
    expect(scribe.data.ocr.active[0].lines[0].words[0].bbox).toEqual({
      left: 1014, top: 83, right: 1234, bottom: 146,
    });

    scribe.opt.displayMode = 'proof';
    scribe.opt.addOverlay = true;
    const exportedPdf = /** @type {ArrayBuffer} */ (await scribe.exportData('pdf'));

    await scribe.clear();
    scribe.opt.usePDFText.native.main = true;
    scribe.opt.keepPDFTextAlways = true;
    await scribe.importFiles({ pdfFiles: [exportedPdf] });

    // In proof mode the export keeps the source text and adds a coloured overlay copy.
    // Re-importing yields two "UNITED" words; they should sit at the same horizontal
    // position and approximately the same baseline. (The overlay uses scribe's NimbusRoman
    // metrics rather than the source's embedded font, so the bbox vertical extent differs
    // by a handful of pixels from font ascent/descent — this is not a positioning bug.)
    // Pre-fix the overlay was offset to (1007, 38, 1234, 118): dx=-7 px, dy=-45 px.
    const reImportedWords = scribe.data.ocr.pdf[0].lines.flatMap((l) => l.words);
    const unitedWords = reImportedWords.filter((w) => w.text === 'UNITED');
    expect(unitedWords.length).toBe(2);
    const sortedByTop = unitedWords.slice().sort((a, b) => a.bbox.top - b.bbox.top);
    expect(sortedByTop[0].bbox).toEqual({
      left: 1014, top: 74, right: 1234, bottom: 152,
    });
    expect(sortedByTop[1].bbox).toEqual({
      left: 1014, top: 83, right: 1234, bottom: 146,
    });

    scribe.opt.displayMode = 'proof';
    scribe.opt.usePDFText.native.main = false;
    scribe.opt.keepPDFTextAlways = false;
    await scribe.clear();
  });

  test('Human-readable PDF with images hex-encodes image streams', async () => {
    scribe.opt.displayMode = 'proof';
    scribe.opt.humanReadablePDF = true;

    await scribe.importFiles([`${ASSETS_PATH}/testocr.png`, `${ASSETS_PATH}/testocr.abbyy.xml`]);
    const exportedPdf = /** @type {ArrayBuffer} */ (await scribe.exportData('pdf'));

    scribe.opt.humanReadablePDF = false;

    const pdfBytes = new Uint8Array(exportedPdf);
    const pdfText = new TextDecoder().decode(pdfBytes);

    // Image streams should use ASCIIHexDecode in a filter array.
    expect(pdfText).toContain('/ASCIIHexDecode');
    // No FlateDecode should appear as a standalone filter (only inside filter arrays).
    expect(pdfText).not.toContain('/Filter/FlateDecode');
    expect(pdfText).not.toContain('/Filter /FlateDecode');

    // Skip the first 30 bytes (well past the header + marker comment) when asserting
    // the rest of the file is human-readable ASCII.
    let allAsciiBody = true;
    for (let i = 30; i < pdfBytes.length; i++) {
      if (pdfBytes[i] > 127) { allAsciiBody = false; break; }
    }
    expect(allAsciiBody).toBe(true);

    await scribe.clear();
  });

  afterAll(async () => {
    await scribe.terminate();
  });
});

describe('Check addHighlights and clearHighlights.', () => {
  test('Should import document for highlight tests', async () => {
    await scribe.importFiles([`${ASSETS_PATH}/testocr.abbyy.xml`]);
    expect(scribe.data.ocr.active[0].lines.length).toBe(8);
  });

  test('addHighlights with startLine/endLine creates one annotation per word on that line', async () => {
    const result = scribe.addHighlights([{ page: 0, startLine: 0, endLine: 0 }]);
    expect(result.highlightsApplied).toBe(1);
    expect(result.totalLinesHighlighted).toBe(1);
    // Line 0 has 11 words: "This is a lot of 12 point text to test the"
    expect(scribe.data.annotations.pages[0].length).toBe(11);
  });

  test('clearHighlights removes all programmatic highlights', async () => {
    scribe.clearHighlights();
    expect(scribe.data.annotations.pages[0].length).toBe(0);
  });

  test('addHighlights with text in quote-only mode highlights matching words', async () => {
    // "ocr code" matches 2 words on line 1
    const result = scribe.addHighlights([{ page: 0, text: 'ocr code' }]);
    expect(result.highlightsApplied).toBe(1);
    expect(scribe.data.annotations.pages[0].length).toBe(2);
    scribe.clearHighlights();
  });

  test('addHighlights throws when neither startLine nor text is provided', async () => {
    expect(() => scribe.addHighlights([{ page: 0 }])).toThrow(undefined);
  });

  test('addHighlights reports each applied highlight in groups with its union bbox (line mode)', async () => {
    scribe.clearHighlights();
    const result = scribe.addHighlights([{ page: 0, startLine: 0, endLine: 0 }]);
    expect(result.groups.length).toBe(1);
    expect(result.groups[0].page).toBe(0);
    expect(result.groups[0].groupId).toBe('hl-0');
    expect(result.groups[0].bbox).toEqual({
      left: 36, top: 92, right: 580, bottom: 122,
    });
    expect(scribe.data.ocr.active[0].dims.height).toBe(480);
    const fracY = result.groups[0].bbox.top / scribe.data.ocr.active[0].dims.height;
    expect(fracY).toBeCloseTo(0.1917, 4);
  });

  test('addHighlights groups: quote-only mode reports the bbox of the matched words', async () => {
    scribe.clearHighlights();
    const result = scribe.addHighlights([{ page: 0, text: 'ocr code' }]);
    expect(result.groups.length).toBe(1);
    expect(result.groups[0].groupId).toBe('hl-0');
    expect(result.groups[0].bbox).toEqual({
      left: 36, top: 126, right: 160, bottom: 150,
    });
  });

  afterAll(async () => {
    await scribe.terminate();
  });
});
