一键导入
pdf-test-data-generation
Generate PDF test data with true in-place content stream mutations for integration testing
用 Codex 或 Claude 帮你安装 复制这段 Prompt,粘贴到 Codex、Claude 或其他助手里,让它检查 Skill 页面并帮你完成安装。
菜单
Generate PDF test data with true in-place content stream mutations for integration testing
用 Codex 或 Claude 帮你安装 复制这段 Prompt,粘贴到 Codex、Claude 或其他助手里,让它检查 Skill 页面并帮你完成安装。
基于 SOC 职业分类
| name | pdf-test-data-generation |
| description | Generate PDF test data with true in-place content stream mutations for integration testing |
Scope: This skill covers the how for PDF file operations — content stream mutation using muhammara, coordinate mapping, establishment number targeting, and validation. It is designed to be loaded alongside the
generate-test-data-from-sampleprompt, which defines the what (business rules, scenario selection, and validation requirements).
Use this skill when: Generating PDF test data files where the app's PDF parser must see mutated content (not just visual overlays), particularly for scenarios requiring authentic field mutations for parsing validation.
Before any mutation, copy the template to the scenario output path:
Copy-Item "src/packing-lists/{exporter}/HappyPath.pdf" "src/packing-lists/{exporter}/test-scenarios/{scenario}/{filename}.pdf"
Never mutate the source template in place.
When to use: When you need authentic PDF mutations that the app will actually parse (not visual overlays).
Pre-condition: Verify the PDF contains real text streams before proceeding. If the PDF is scanned or image-only and text is not targetable in the content stream, stop and report the limitation clearly — do not silently produce an unmodified file.
Use muhammara library with low-level content stream replacement for true in-place edits:
Install:
npm install --save-dev muhammara
import muhammara from 'muhammara'
import zlib from 'node:zlib'
import { readFileSync } from 'node:fs'
const templatePath = 'src/packing-lists/{exporter}/HappyPath.pdf'
const outputPath =
'src/packing-lists/{exporter}/test-scenarios/{scenario}/{filename}.pdf'
// --- Helper: decode a FlateDecode content stream by object ID ---
function decodeStream(pdfBytes, reader, objId) {
const stream = reader.parseNewObject(objId).toPDFStream()
const dict = stream.getDictionary()
const len = dict.queryObject('Length').toNumber()
const start = stream.getStreamContentStart()
const compressed = pdfBytes.subarray(start, start + len)
// CRITICAL: use 'latin1', not 'utf8' — bytes above 0x7F in font/rendering
// instructions will be silently corrupted by a utf8 decode/re-encode round-trip.
return zlib.inflateSync(compressed).toString('latin1')
}
// --- Find data stream object IDs ---
// Multi-page PDFs split content across many objects. Page 0 contains headers
// + first page data rows. Additional pages each have their own object IDs
// (data rows only, no headers). Enumerate all before bulk mutations.
const pdfBytes = readFileSync(templatePath)
const reader = muhammara.createReader(templatePath)
const pageDict = reader.parsePageDictionary(0)
const contents = pageDict.queryObject('Contents').toPDFArray()
let page0ObjectId, decoded
for (let i = 0; i < contents.getLength(); i++) {
const id = contents
.queryObject(i)
.toPDFIndirectObjectReference()
.getObjectID()
try {
const candidate = decodeStream(pdfBytes, reader, id)
// Verify this is the data stream — not a font table or certification page
if (candidate.includes('(Description of Goods)Tj')) {
page0ObjectId = id
decoded = candidate
break
}
} catch (_) {
/* not a decompressible FlateDecode stream — skip */
}
}
// For multi-page PDFs, collect all data stream object IDs (one per page)
// and store them for bulk "All" mutations:
// const allDataObjectIds = [page0ObjectId, ...otherPageObjectIds]
// --- Mutation helpers ---
const escapePdfLiteral = (v) =>
v.replace(/\\/g, '\\\\').replace(/\(/g, '\\(').replace(/\)/g, '\\)')
// Replace data tokens in a decoded stream by x-coordinate band.
//
// KEY POINTS:
// 1. Use a REGEX RANGE for right-justified fields — their x varies per row
// with text width (e.g., country_of_origin spans 223.91–225.97 across rows).
// Use '22[3-6]\\.[0-9]+' not a single literal like '223\\.91'.
//
// 2. Use a NEGATIVE LOOKAHEAD (?!\/F2 ) to avoid matching header tokens
// (font F2) when your x-coordinate overlaps with a header position.
//
// 3. The font declaration (?:\/F1 6\.75 Tf\n)? is OPTIONAL — wrapped/continuation
// tokens (a value too long for one line) omit the repeated Tf. Without the
// optional group, those continuation tokens are silently skipped.
function replaceData(decoded, xPattern, replacer, max = Infinity) {
const re = new RegExp(
`(1 0 0 1 ${xPattern} [\\d.]+ Tm\\n)(?!\\/F2 )(?:\\/F1 6\\.75 Tf\\n)?\\((.*?)\\)Tj`,
'g'
)
let n = 0
return decoded.replace(re, (match, tmLine, value) => {
if (n >= max) return match
n++
const newValue =
typeof replacer === 'function' ? replacer(value, n) : replacer
return match.replace(`(${value})Tj`, `(${newValue})Tj`)
})
}
// Blank all F1-6.75 data tokens across a stream while preserving the
// establishment number. If the establishment number uses a DIFFERENT font size
// (e.g., F1 7.5 Tf vs F1 6.75 Tf for data rows), targeting only 6.75 will
// leave it intact — which is what you need for NoData_ExceptSingleRMS.
function blankAllF1Data(decoded) {
return decoded.replace(
/(1 0 0 1 [\d.]+ [\d.]+ Tm\n)(?!\/F2 )(?:\/F1 6\.75 Tf\n)?\((.*?)\)Tj/g,
(match, tmLine, value) => match.replace(`(${value})Tj`, '()Tj')
)
}
// --- Example: single field mutation on page 0 ---
let updated = replaceData(
decoded,
'75\\.26',
escapePdfLiteral('INVALID TEXT'),
1
)
if (updated === decoded) {
throw new Error('Mutation failed: pattern not found in decoded stream')
}
// --- Write back using muhammara ---
// CRITICAL: getWriteStream().write() requires a plain JS array of byte integers,
// NOT a Buffer. Passing a Buffer directly silently writes nothing.
const writer = muhammara.createWriterToModify(templatePath, {
modifiedFilePath: outputPath
})
const oc = writer.getObjectsContext()
oc.startModifiedIndirectObject(page0ObjectId)
const s = oc.startUnfilteredPDFStream()
s.getWriteStream().write(Array.from(Buffer.from(updated, 'latin1')))
oc.endPDFStream(s)
oc.endIndirectObject()
writer.end()
Key advantages:
Coordinate mapping process:
1 0 0 1 X Y Tm\n/Font Size Tf\n(text)TjValidation:
// After mutation, verify using pdf.js-extract (the same extractor the parser uses)
import pkg from 'pdf.js-extract'
const { PDFExtract } = pkg
const extractor = new PDFExtract()
const data = await extractor.extract(outputPath, {})
// IMPORTANT: pdf.js-extract merges adjacent tokens at the same Y position into
// one string. E.g., 'Treatment Type' + 'NIRMS' become 'TREATMENT TYPE NIRMS'.
// Always use .includes() or a substring check, NOT strict === equality.
const page0Tokens = data.pages[0].content
const descValues = page0Tokens
.filter((t) => t.x > 74 && t.x < 77)
.map((t) => t.str)
// IMPORTANT: Empty ()Tj tokens are NOT returned by pdf.js-extract at all.
// For 'blank' mutations, verify absence of the original value, not presence of ''.
const hasMutatedValue = descValues.some((v) => v.includes('INVALID TEXT'))
if (!hasMutatedValue) throw new Error('Mutation not visible to extractor')
Establishment numbers in PDFs can appear in one of two locations — determine which applies from the template before mutating:
All_Fail).overlay-pdf-tool (Quick Prototyping Only)When to use: When you only need visual test screenshots and the app won't actually parse the PDF content.
import { PDFDocument } from 'overlay-pdf-tool'
import { readFileSync, writeFileSync } from 'node:fs'
const inputBytes = readFileSync(inputFile)
const pdfDoc = await PDFDocument.load(inputBytes)
const page = pdfDoc.getPage(0)
// Overlay text at known coordinates (NOT true mutation)
page.drawText('INVALID', { x: 420, y: 510, size: 10 })
const outBytes = await pdfDoc.save()
writeFileSync(outputFile, outBytes)
Limitations:
Copy template via PowerShell/CLI:
Copy-Item "src/packing-lists/{exporter}/HappyPath.pdf" "src/packing-lists/{exporter}/test-scenarios/{scenario}/{filename}.pdf"
Map coordinates from template:
muhammara to enumerate page content stream object IDszlib.inflateSync (decode as latin1)Test one mutation:
Apply remaining mutations:
Validate:
The MandS1 PDF test data generation successfully created 32 mutated PDFs using the in-place stream approach. Key insights:
overlay-pdf-tool drawText) adds visual text but leaves original stream unchangedmuhammara.createReaderzlib.inflateSync() decoded as latin1 (not utf8)'(Description of Goods)Tj'); skip font tables and non-data streams1 0 0 1 X Y Tm\n/F1 6.75 Tf\n(text)Tj1 0 0 1 X Y Tm\n(text)Tj ← no repeated Tf22[3-6]\\.[0-9]+)1 0 0 1 408.40 361.08 Tm # Position: X=408.40, Y=361.08
/F2 7.5 Tf # Font F2, size 7.5
(NIRMS)Tj # Text: "NIRMS"
muhammara (installed as a dev dep). Do not use placeholder names.write() requires an array, not a Buffer: ws.write(Buffer.from(...)) silently writes nothing — use ws.write(Array.from(Buffer.from(content, 'latin1'))).latin1 encoding is mandatory: Use .toString('latin1') when decompressing and Buffer.from(content, 'latin1') when re-encoding. UTF-8 corrupts bytes above 0x7F found in font and rendering instructions.Tf line: Long values that wrap onto a second line emit 1 0 0 1 X Y Tm\n(value)Tj with no repeated font declaration. A regex requiring /F1 6\.75 Tf\n will silently skip them. Use (?:\/F1 6\.75 Tf\n)? to make it optional.(?!\/F2 ) after the Tm\n to avoid corrupting header tokens.country_of_origin shift x with text width across rows. Use a regex range (e.g., 22[3-6]\.[0-9]+) not a single literal x value.\, (, and ) must be escaped in PDF string literals.408.40 vs 408.4 are different strings in the stream — match exactly.pdf.js-extract merges adjacent tokens: Tokens at the same Y are concatenated into one string (e.g., 'Treatment Type' + 'NIRMS' → 'TREATMENT TYPE NIRMS'). Use .includes() not === when verifying header mutations.pdf.js-extract does not return empty tokens: An empty ()Tj mutation is correct but the extractor simply omits it. Verify blank mutations by checking for the absence of the original value, not the presence of an empty string.F1 7.5 Tf while data rows use F1 6.75 Tf, a regex targeting only 6.75 blanks all data without touching the establishment number — use this for the NoData_ExceptSingleRMS scenario.Critical Understanding: PDF content streams use different coordinates than visual page extraction.
Stream Token Coordinates (what you mutate): X,Y values in 1 0 0 1 X Y Tm patterns
1 0 0 1 408.40 361.08 Tm places text at stream position (408.40, 361.08)Extracted Page Coordinates (what extraction tools report): Normalized to visual page layout
Implementation guidance: Always use stream token coordinates for mutations, verify with re-extraction after mutation.
Defra software development standards for Node.js and .NET government services. Use when writing, reviewing, or refactoring code in a Defra digital service repository. Activates for Hapi framework projects, CDP platform services, and .NET ASP.NET Core Minimal API services.
Generate Excel test data with style-safe workbook mutations, merged-cell handling, and mapping verification for scenario-based test suites
Generate CSV test data with correct quoting, encoding, and PowerShell-based mutation patterns for scenario-based test suites