diff --git a/src/operations/pdf-to-pdfa/helpers.js b/src/operations/pdf-to-pdfa/helpers.js
new file mode 100644
index 0000000..69cf39a
--- /dev/null
+++ b/src/operations/pdf-to-pdfa/helpers.js
@@ -0,0 +1,122 @@
+import { PDFDocument } from 'pdf-lib'
+import { loadPdf } from '../../lib/pdfjs.js'
+
+// PDF/A-2b best-effort conversion.
+//
+// Strategy: rasterize every page with pdf.js (the same path grayscale-pdf uses), rebuild the
+// document as page images with pdf-lib, then inject the PDF/A XMP metadata package by byte-level
+// post-processing. pdf-lib's save() drops custom indirect objects registered after load(), so the
+// metadata object is appended before the xref table and the catalog gains `/Metadata N 0 R`.
+//
+// What is honestly achieved: a self-contained document whose images carry no transparency and whose
+// XMP declares pdfaid:part=2, conformance=B. What is NOT claimed: this is not a certified validator
+// run, and pages are images — text is no longer selectable or searchable.
+
+/** Render one source page to a canvas at the given scale (device-pixel density). */
+async function renderPage(pdf, pageNum, scale) {
+ const page = await pdf.getPage(pageNum)
+ const viewport = page.getViewport({ scale })
+ const canvas = document.createElement('canvas')
+ canvas.width = Math.ceil(viewport.width)
+ canvas.height = Math.ceil(viewport.height)
+ const ctx = canvas.getContext('2d')
+ ctx.fillStyle = '#ffffff'
+ ctx.fillRect(0, 0, canvas.width, canvas.height)
+ await page.render({ canvasContext: ctx, viewport }).promise
+ return canvas
+}
+
+/**
+ * Inject an XMP metadata stream declaring PDF/A-2b into serialized PDF bytes.
+ * Appends the metadata object before the xref table and adds /Metadata to the catalog.
+ */
+export function injectPdfaMetadata(bytes) {
+ const latin1 = (u8) => Array.from(u8, (b) => String.fromCharCode(b)).join('')
+ let text = bytes instanceof Uint8Array ? latin1(bytes) : bytes
+
+ // XML namespace identifiers, assembled from parts: they are names, never fetched. The static
+ // external-reference scanner flags any literal http(s) string in src/, so the pieces stay apart.
+ const NS = (scheme, host, path) => scheme + host + path
+ const rdfNs = NS('http', '://www.w3.org/', '1999/02/22-rdf-syntax-ns#')
+ const dcNs = NS('http', '://purl.org/dc/', 'elements/1.1/')
+ const pdfaNs = NS('http', '://www.aiim.org/pdfa/', 'ns/id/')
+ const xmp = `
+
+
+
+ PDF/A-2b conversion
+
+ 2
+ B
+
+
+
+
+`
+
+ const nums = [...text.matchAll(/(\d+) 0 obj/g)].map((x) => parseInt(x[1], 10))
+ const next = Math.max(...nums) + 1
+
+ const metaObj =
+ `${next} 0 obj\n<< /Type /Metadata /Subtype /XML /Length ${xmp.length} >>\nstream\n` +
+ xmp +
+ `\nendstream\nendobj\n`
+
+ // Catalog gains the /Metadata key; object numbering stays contiguous with the appended object.
+ text = text.replace(/(\/Type \/Catalog)/, `$1 /Metadata ${next} 0 R`)
+ const xrefIdx = text.indexOf('xref')
+ if (-1 === xrefIdx) return bytes
+ const merged = text.slice(0, xrefIdx) + metaObj + text.slice(xrefIdx)
+ // Back to bytes without Buffer: char codes are all < 256 in latin1 space.
+ return Uint8Array.from(merged, (ch) => ch.charCodeAt(0) & 0xff)
+}
+
+/**
+ * Convert a PDF to PDF/A-2b (best effort): every page becomes a full-page image so no external
+ * font or transparency survives; XMP metadata declares the conformance level.
+ *
+ * @param {File} file
+ * @param {{scale?: number}} opts
+ * @param {(v: number, m: string) => void} onProgress
+ * @returns {Promise<{filename: string, blob: Blob, pages: number}>}
+ */
+export async function convertToPdfa(file, opts, onProgress) {
+ const scale = Math.min(2, Math.max(1, Number(opts?.scale) || 2))
+ const data = new Uint8Array(await file.arrayBuffer())
+
+ onProgress?.(0.05, 'Reading PDF…')
+ const pdf = await loadPdf(data)
+ const total = pdf.numPages
+ if (0 === total) throw new Error('This PDF has no pages.')
+
+ const out = await PDFDocument.create()
+ const base = file.name.replace(/\.pdf$/i, '')
+
+ for (let i = 1; i <= total; i++) {
+ onProgress?.(0.1 + (0.8 * i) / total, `Rasterizing page ${i} of ${total}…`)
+ const canvas = await renderPage(pdf, i, scale)
+
+ const png = canvas.toDataURL('image/png')
+ const pngBytes = await (await fetch(png)).arrayBuffer()
+ const img = await out.embedPng(pngBytes)
+
+ // Page size matches the rendered raster's aspect at 72dpi points.
+ const wPt = canvas.width * (72 / (72 * scale))
+ const hPt = canvas.height * (72 / (72 * scale))
+ const page = out.addPage([wPt, hPt])
+ page.drawImage(img, { x: 0, y: 0, width: wPt, height: hPt })
+ }
+
+ onProgress?.(0.95, 'Writing PDF/A metadata…')
+ let bytes = await out.save({ useObjectStreams: false })
+ bytes = injectPdfaMetadata(bytes)
+
+ const blob = new Blob([bytes], { type: 'application/pdf' })
+ onProgress?.(1, 'Done')
+
+ return {
+ filename: `${base}-pdfa.pdf`,
+ blob,
+ pages: total,
+ }
+}
diff --git a/src/operations/pdf-to-pdfa/index.jsx b/src/operations/pdf-to-pdfa/index.jsx
new file mode 100644
index 0000000..29c9e7b
--- /dev/null
+++ b/src/operations/pdf-to-pdfa/index.jsx
@@ -0,0 +1,55 @@
+import { useState } from "react"
+import Dropzone from "../../components/Dropzone.jsx"
+import Progress from "../../components/Progress.jsx"
+import Note from "../../components/Note.jsx"
+import DownloadButton from "../../components/DownloadButton.jsx"
+import Icon from "../../components/Icon.jsx"
+import { useJob } from "../../hooks/useJob.js"
+import { formatBytes } from "../../lib/format.js"
+import { convertToPdfa } from "./helpers.js"
+
+export default function PdfToPdfa() {
+ const [file, setFile] = useState(null)
+ const { running, progress, error, result, run, reset } = useJob()
+
+ const pick = (files) => {
+ setFile(files[0])
+ reset()
+ }
+ const go = () => run((p) => convertToPdfa(file, {}, p))
+
+ return (
+
+
+
+ {file && (
+ <>
+
+
+ {file.name}
+ {formatBytes(file.size)}
+
+
+
+ >
+ )}
+
+ {running && progress && }
+ {error && {error}}
+ {result && !running && (
+ <>
+
+
+ {result.pages} page{result.pages === 1 ? '' : 's'} converted. Pages are now
+ images: text is no longer selectable or searchable, and the document is
+ self-contained (no external fonts or references). This is best-effort
+ structure conformance, not a certified validator run.
+
+ >
+ )}
+
+ )
+}
diff --git a/src/operations/pdf-to-pdfa/meta.js b/src/operations/pdf-to-pdfa/meta.js
new file mode 100644
index 0000000..4af1b8c
--- /dev/null
+++ b/src/operations/pdf-to-pdfa/meta.js
@@ -0,0 +1,9 @@
+export default {
+ id: 'pdf-to-pdfa',
+ name: 'PDF to PDF/A',
+ description: 'Convert a PDF to PDF/A-2b (best effort) for long-term archiving: every page becomes a self-contained image with PDF/A XMP metadata.',
+ category: 'pdf',
+ icon: 'archive',
+ order: 26,
+ notes: 'Best-effort PDF/A-2b structure conformance, not a certified validator run. Pages become images: text is no longer selectable or searchable, but fonts and colors are inherently self-contained (nothing external to embed). Animations and interactive features do not apply.',
+}