1 Commits
Author SHA1 Message Date
zemion c40a2c4b2a Release Format Lab 0.2.0
Verify / verify (push) Canceled after 0s
2026-09-02 07:16:08 +02:00
29 changed files with 972 additions and 64 deletions
+39
View File
@@ -0,0 +1,39 @@
name: Verify
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
concurrency:
group: verify-${{ gitea.repository }}-${{ gitea.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
verify:
runs-on: ubuntu-latest
timeout-minutes: 45
env:
CI: "true"
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: npm
- name: Select declared npm version
run: npm install --global npm@11.17.0
- name: Install dependencies
run: npm ci
- name: Audit runtime dependencies
run: npm audit --omit=dev --audit-level=moderate
- name: Check, test, and build
run: npm run check
- name: Install browser engines
run: npx playwright install --with-deps chromium firefox webkit
- name: Browser tests
run: npm run test:browser
+5
View File
@@ -1,5 +1,10 @@
# Changelog
## 0.2.0 - 2026-09-02
- Add shared evidence envelopes, a reusable format catalogue and bounded local
corpus runs for comparing measured round-trip behaviour across recipes.
## 0.1.0 - 2026-09-01
- Add property-aware conversion graphs for data, image, audio/video, and
+9 -2
View File
@@ -4,7 +4,7 @@ Explore conversion paths, state the expected preservation boundary, and measure
what a representative round trip actually changes. Format Lab is a local-first
module in the add·ideas Toolbox.
## v0.1.0 capabilities
## v0.2.0 capabilities
- Browse directed format graphs for structured data, images, audio/video, and
subtitles
@@ -20,11 +20,14 @@ module in the add·ideas Toolbox.
untested when it is outside the canonical model
- Export the target data and a deterministic evidence report containing the
catalog version, exact path, expectations, measurements, and notices
- Export a versioned portable evidence contract shared with other audit tools,
inspect a broad masked-offset file-signature catalog, and download a
deterministic edge-case corpus as individually named fixtures plus manifest
## Honest scope
The image, audio/video, and subtitle graphs are planning contracts linked to the
specialized Toolbox apps; v0.1.0 does not duplicate their codec or renderer
specialized Toolbox apps; v0.2.0 does not duplicate their codec or renderer
engines. Catalog statuses are conservative expectations, not measurements of a
particular file or encoder. The executable data lab supports records only—not
arbitrary JSON documents, XML mixed content/namespaces, or an external CSV type
@@ -34,6 +37,10 @@ Inputs are capped at 2 MiB, 10,000 records, 200 fields per record, 500,000
top-level values, and 8 MiB output. CSV cells are strings and spreadsheet-formula
prefixes are neutralized; the resulting semantic change is visible in the
round-trip measurement.
Signature matches identify byte patterns and likely containers only; they do
not validate an entire file or establish that its content is safe. Parquet,
Arrow, YAML, AVIF/HEIC/TIFF/GIF, Ogg/M4A/MOV/AAC and SBV/SAMI appear in the
planning catalog without inventing executable conversion support.
## Development
+2 -2
View File
@@ -1,7 +1,7 @@
# Corresponding source
The corresponding source for Format Lab 0.1.0 is available at:
The corresponding source for Format Lab 0.2.0 is available at:
https://git.add-ideas.de/lotobo/format-lab/src/tag/v0.1.0
https://git.add-ideas.de/lotobo/format-lab/src/tag/v0.2.0
Build with Node.js 22, npm 11, `npm ci`, and `npm run release:artifact`.
+4 -4
View File
@@ -1,12 +1,12 @@
# Third-party notices
Format Lab 0.1.0 directly depends on these runtime packages:
Format Lab 0.2.0 directly depends on these runtime packages:
| Package | Pinned version | Declared licence |
| -------------------------------- | -------------: | ---------------- |
| `@add-ideas/toolbox-contract` | 0.2.3 | Apache-2.0 |
| `@add-ideas/toolbox-helpers` | 0.1.0 | GPL-3.0-or-later |
| `@add-ideas/toolbox-shell-react` | 0.2.3 | Apache-2.0 |
| `@add-ideas/toolbox-contract` | 0.3.0 | Apache-2.0 |
| `@add-ideas/toolbox-helpers` | 0.2.0 | GPL-3.0-or-later |
| `@add-ideas/toolbox-shell-react` | 0.3.0 | Apache-2.0 |
| `react` | 19.2.8 | MIT |
| `react-dom` | 19.2.8 | MIT |
+19 -19
View File
@@ -1,22 +1,22 @@
{
"name": "format-lab",
"version": "0.1.0",
"version": "0.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "format-lab",
"version": "0.1.0",
"version": "0.2.0",
"license": "GPL-3.0-or-later",
"dependencies": {
"@add-ideas/toolbox-contract": "0.2.3",
"@add-ideas/toolbox-helpers": "0.1.0",
"@add-ideas/toolbox-shell-react": "0.2.3",
"@add-ideas/toolbox-contract": "0.3.0",
"@add-ideas/toolbox-helpers": "0.2.0",
"@add-ideas/toolbox-shell-react": "0.3.0",
"react": "19.2.8",
"react-dom": "19.2.8"
},
"devDependencies": {
"@add-ideas/toolbox-testkit": "0.2.3",
"@add-ideas/toolbox-testkit": "0.3.0",
"@eslint/js": "10.0.1",
"@playwright/test": "1.62.1",
"@testing-library/jest-dom": "6.9.1",
@@ -42,24 +42,25 @@
}
},
"node_modules/@add-ideas/toolbox-contract": {
"version": "0.2.3",
"license": "Apache-2.0",
"engines": {
"node": ">=20"
}
"version": "0.3.0",
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-contract/-/0.3.0/toolbox-contract-0.3.0.tgz",
"integrity": "sha512-dKrK7BjOFwqJaBfJuhKxZKIld4sH0AKjEn6a0yLnbdMUFY+fFv4VSLGV2tNSBD016gumc2iNqOjUj/ld7x4rtA==",
"license": "Apache-2.0"
},
"node_modules/@add-ideas/toolbox-helpers": {
"version": "0.1.0",
"version": "0.2.0",
"license": "GPL-3.0-or-later",
"engines": {
"node": ">=22"
}
},
"node_modules/@add-ideas/toolbox-shell-react": {
"version": "0.2.3",
"version": "0.3.0",
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-shell-react/-/0.3.0/toolbox-shell-react-0.3.0.tgz",
"integrity": "sha512-74p6JzAOG0YCAKdlc1hLofV4ZIko7vb448S75cIiM88PKm93EHl5VD7g8YVyfM56Ui97UY9dmy+Whiq4sGzpsg==",
"license": "Apache-2.0",
"dependencies": {
"@add-ideas/toolbox-contract": "0.2.3"
"@add-ideas/toolbox-contract": "0.3.0"
},
"peerDependencies": {
"react": ">=18 <20",
@@ -67,17 +68,16 @@
}
},
"node_modules/@add-ideas/toolbox-testkit": {
"version": "0.2.3",
"version": "0.3.0",
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-testkit/-/0.3.0/toolbox-testkit-0.3.0.tgz",
"integrity": "sha512-4Fk+oSvZFspOMIXr8Xy040nhAaBsIQAzsGyXWSpjn3+k3yBKq7nB1r5zCHhsXzfdLzvPDAx2KcmSNOhM330D9w==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@add-ideas/toolbox-contract": "0.2.3"
"@add-ideas/toolbox-contract": "0.3.0"
},
"bin": {
"toolbox-check": "dist/cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@adobe/css-tools": {
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "format-lab",
"version": "0.1.0",
"version": "0.2.0",
"description": "Explore conversion paths and measure format round-trip losses locally.",
"license": "GPL-3.0-or-later",
"author": "Albrecht Degering",
@@ -39,14 +39,14 @@
"release:artifact": "npm run check && npm run test:browser && npm run package:release -- --force"
},
"dependencies": {
"@add-ideas/toolbox-contract": "0.2.3",
"@add-ideas/toolbox-helpers": "0.1.0",
"@add-ideas/toolbox-shell-react": "0.2.3",
"@add-ideas/toolbox-contract": "0.3.0",
"@add-ideas/toolbox-helpers": "0.2.0",
"@add-ideas/toolbox-shell-react": "0.3.0",
"react": "19.2.8",
"react-dom": "19.2.8"
},
"devDependencies": {
"@add-ideas/toolbox-testkit": "0.2.3",
"@add-ideas/toolbox-testkit": "0.3.0",
"@eslint/js": "10.0.1",
"@playwright/test": "1.62.1",
"@testing-library/jest-dom": "6.9.1",
+20 -2
View File
@@ -15,7 +15,25 @@ export default defineConfig({
timeout: 180_000,
},
projects: [
{ name: "chromium", use: { ...devices["Desktop Chrome"] } },
{ name: "firefox", use: { ...devices["Desktop Firefox"] } },
{
name: "chromium",
testIgnore: /responsive\.spec\.ts/,
use: { ...devices["Desktop Chrome"] },
},
{
name: "firefox",
testIgnore: /responsive\.spec\.ts/,
use: { ...devices["Desktop Firefox"] },
},
{
name: "webkit",
testIgnore: /responsive\.spec\.ts/,
use: { ...devices["Desktop Safari"] },
},
{
name: "mobile-chromium",
testMatch: /responsive\.spec\.ts/,
use: { ...devices["Pixel 5"] },
},
],
});
+5
View File
@@ -1,5 +1,10 @@
# Changelog
## 0.2.0 - 2026-09-02
- Add shared evidence envelopes, a reusable format catalogue and bounded local
corpus runs for comparing measured round-trip behaviour across recipes.
## 0.1.0 - 2026-09-01
- Add property-aware conversion graphs for data, image, audio/video, and
+3 -3
View File
@@ -1,5 +1,5 @@
==============================================================================
@add-ideas/toolbox-contract@0.2.3
@add-ideas/toolbox-contract@0.3.0
Declared licence: Apache-2.0
==============================================================================
--- LICENSE ---
@@ -198,7 +198,7 @@ Declared licence: Apache-2.0
==============================================================================
@add-ideas/toolbox-helpers@0.1.0
@add-ideas/toolbox-helpers@0.2.0
Declared licence: GPL-3.0-or-later
==============================================================================
--- LICENSE ---
@@ -879,7 +879,7 @@ Public License instead of this License. But first, please read
==============================================================================
@add-ideas/toolbox-shell-react@0.2.3
@add-ideas/toolbox-shell-react@0.3.0
Declared licence: Apache-2.0
==============================================================================
--- LICENSE ---
+9 -2
View File
@@ -4,7 +4,7 @@ Explore conversion paths, state the expected preservation boundary, and measure
what a representative round trip actually changes. Format Lab is a local-first
module in the add·ideas Toolbox.
## v0.1.0 capabilities
## v0.2.0 capabilities
- Browse directed format graphs for structured data, images, audio/video, and
subtitles
@@ -20,11 +20,14 @@ module in the add·ideas Toolbox.
untested when it is outside the canonical model
- Export the target data and a deterministic evidence report containing the
catalog version, exact path, expectations, measurements, and notices
- Export a versioned portable evidence contract shared with other audit tools,
inspect a broad masked-offset file-signature catalog, and download a
deterministic edge-case corpus as individually named fixtures plus manifest
## Honest scope
The image, audio/video, and subtitle graphs are planning contracts linked to the
specialized Toolbox apps; v0.1.0 does not duplicate their codec or renderer
specialized Toolbox apps; v0.2.0 does not duplicate their codec or renderer
engines. Catalog statuses are conservative expectations, not measurements of a
particular file or encoder. The executable data lab supports records only—not
arbitrary JSON documents, XML mixed content/namespaces, or an external CSV type
@@ -34,6 +37,10 @@ Inputs are capped at 2 MiB, 10,000 records, 200 fields per record, 500,000
top-level values, and 8 MiB output. CSV cells are strings and spreadsheet-formula
prefixes are neutralized; the resulting semantic change is visible in the
round-trip measurement.
Signature matches identify byte patterns and likely containers only; they do
not validate an entire file or establish that its content is safe. Parquet,
Arrow, YAML, AVIF/HEIC/TIFF/GIF, Ogg/M4A/MOV/AAC and SBV/SAMI appear in the
planning catalog without inventing executable conversion support.
## Development
+2 -2
View File
@@ -1,7 +1,7 @@
# Corresponding source
The corresponding source for Format Lab 0.1.0 is available at:
The corresponding source for Format Lab 0.2.0 is available at:
https://git.add-ideas.de/lotobo/format-lab/src/tag/v0.1.0
https://git.add-ideas.de/lotobo/format-lab/src/tag/v0.2.0
Build with Node.js 22, npm 11, `npm ci`, and `npm run release:artifact`.
+4 -4
View File
@@ -1,12 +1,12 @@
# Third-party notices
Format Lab 0.1.0 directly depends on these runtime packages:
Format Lab 0.2.0 directly depends on these runtime packages:
| Package | Pinned version | Declared licence |
| -------------------------------- | -------------: | ---------------- |
| `@add-ideas/toolbox-contract` | 0.2.3 | Apache-2.0 |
| `@add-ideas/toolbox-helpers` | 0.1.0 | GPL-3.0-or-later |
| `@add-ideas/toolbox-shell-react` | 0.2.3 | Apache-2.0 |
| `@add-ideas/toolbox-contract` | 0.3.0 | Apache-2.0 |
| `@add-ideas/toolbox-helpers` | 0.2.0 | GPL-3.0-or-later |
| `@add-ideas/toolbox-shell-react` | 0.3.0 | Apache-2.0 |
| `react` | 19.2.8 | MIT |
| `react-dom` | 19.2.8 | MIT |
+1 -1
View File
@@ -1,5 +1,5 @@
const CACHE_PREFIX = "format-lab-shell-";
const CACHE_NAME = CACHE_PREFIX + "0.1.0";
const CACHE_NAME = CACHE_PREFIX + "0.2.0";
const CORE = ["./", "./manifest.webmanifest", "./favicon.svg"];
self.addEventListener("install", (event) => {
event.waitUntil(
+16 -1
View File
@@ -3,7 +3,7 @@
"schemaVersion": 1,
"id": "de.add-ideas.format-lab",
"name": "Format Lab",
"version": "0.1.0",
"version": "0.2.0",
"description": "Explore conversion paths and measure information loss locally.",
"entry": "./",
"icon": "./favicon.svg",
@@ -28,6 +28,21 @@
"crossOriginIsolated": false,
"topLevelContext": false
},
"io": {
"accepts": [
{ "mediaType": "application/json", "extensions": [".json"] },
{ "mediaType": "application/x-ndjson", "extensions": [".ndjson"] },
{ "mediaType": "text/csv", "extensions": [".csv"] },
{ "mediaType": "application/xml", "extensions": [".xml"] }
],
"produces": [
{ "mediaType": "application/json", "extensions": [".json"] },
{ "mediaType": "application/x-ndjson", "extensions": [".ndjson"] },
{ "mediaType": "text/csv", "extensions": [".csv"] },
{ "mediaType": "application/xml", "extensions": [".xml"] }
]
},
"capabilities": { "required": [], "optional": [] },
"privacy": {
"processing": "local",
"fileUploads": true,
+103 -13
View File
@@ -1,7 +1,7 @@
import { useMemo, useState, type ChangeEvent } from "react";
import {
stableStringify,
triggerBlobDownload,
triggerBlobDownloads,
} from "@add-ideas/toolbox-helpers";
import {
domains,
@@ -19,6 +19,13 @@ import {
type DataFormat,
type RoundTripResult,
} from "../lab/data";
import { exportCorpusFiles } from "../lab/corpus";
import { serializeEvidence } from "../lab/evidence";
import {
formatSignatures,
identifyBySignature,
type FormatSignature,
} from "../lab/signatures";
const SAMPLE = `[
{
@@ -159,6 +166,10 @@ export function Workbench() {
"target",
);
const [error, setError] = useState<string>();
const [signatureMatches, setSignatureMatches] = useState<FormatSignature[]>(
[],
);
const [signatureName, setSignatureName] = useState("No file inspected");
const domainFormats = formats.filter((format) => format.domain === domain);
const routes = useMemo(
@@ -172,18 +183,7 @@ export function Workbench() {
domainFormats.some((format) => format.id === edge.to),
);
const report = useMemo(
() =>
`${stableStringify(
{
catalogVersion: "format-catalog-v1",
route: record.route.formats,
expected: record.route.summary,
measured: record.result.measurements,
notes: record.result.notes,
},
2,
{ maxTextChars: 2 * 1024 * 1024, maxDepth: 16, maxNodes: 20_000 },
)}\n`,
() => serializeEvidence(record.result.evidence),
[record],
);
@@ -240,6 +240,24 @@ export function Workbench() {
setError(undefined);
};
const inspectSignature = async (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
event.target.value = "";
if (!file) return;
const bytes = new Uint8Array(await file.slice(0, 64).arrayBuffer());
setSignatureName(file.name);
setSignatureMatches(identifyBySignature(bytes));
};
const downloadCorpus = () =>
triggerBlobDownloads(
exportCorpusFiles().map((file) => ({
blob: new Blob([file.content], { type: file.mediaType }),
filename: file.path.replaceAll("/", "__"),
})),
{ order: "filename", maximumFiles: 20 },
);
const measuredByProperty = Object.fromEntries(
record.result.measurements.map((measurement) => [
measurement.property,
@@ -408,6 +426,68 @@ export function Workbench() {
</details>
</section>
<section className="panel" aria-labelledby="signature-title">
<div className="panel-heading">
<div>
<h2 id="signature-title">
Signature catalog and regression corpus
</h2>
<p>
{formatSignatures.length} bounded byte signatures identify likely
containers; they do not claim structural validity. {signatureName}
.
</p>
</div>
<div className="action-row">
<label className="button file-button">
Inspect first 64 bytes
<input
type="file"
onChange={(event) => void inspectSignature(event)}
/>
</label>
<button type="button" onClick={downloadCorpus}>
Download fixture corpus
</button>
</div>
</div>
<p className="notice-list">
{signatureMatches.length
? signatureMatches
.map(
(match) =>
`${match.label} (${match.confidence}: ${match.note})`,
)
.join(" · ")
: "No catalog signature matched the inspected prefix."}
</p>
<details>
<summary>Browse all signature definitions</summary>
<div className="table-scroll" tabIndex={0}>
<table>
<thead>
<tr>
<th>Format</th>
<th>Offset</th>
<th>Confidence</th>
<th>Boundary</th>
</tr>
</thead>
<tbody>
{formatSignatures.map((item) => (
<tr key={item.id}>
<td>{item.label}</td>
<td>{item.offset}</td>
<td>{item.confidence}</td>
<td>{item.note}</td>
</tr>
))}
</tbody>
</table>
</div>
</details>
</section>
<section className="panel" aria-labelledby="lab-title">
<div className="panel-heading">
<div>
@@ -510,6 +590,16 @@ export function Workbench() {
Download evidence report
</button>
</div>
<details>
<summary>Portable evidence JSON</summary>
<textarea
aria-label="Evidence report"
readOnly
value={report}
rows={12}
spellCheck={false}
/>
</details>
</div>
</div>
+78
View File
@@ -104,6 +104,24 @@ export const formats: FormatDefinition[] = [
domain: "data",
note: "Flexible tree with namespaces, attributes and mixed content.",
},
{
id: "yaml",
label: "YAML",
domain: "data",
note: "Typed graph-like syntax whose schema, tags, aliases and parser profile affect interoperability.",
},
{
id: "parquet",
label: "Parquet",
domain: "data",
note: "Columnar typed records with schemas, encodings and optional statistics.",
},
{
id: "arrow",
label: "Arrow IPC",
domain: "data",
note: "Typed columnar batches; extension metadata and dictionary handling matter.",
},
{
id: "png",
label: "PNG",
@@ -128,6 +146,30 @@ export const formats: FormatDefinition[] = [
domain: "image",
note: "Vector/XML document with text and external-resource risks.",
},
{
id: "gif",
label: "GIF",
domain: "image",
note: "Palette raster with binary transparency and frame animation.",
},
{
id: "avif",
label: "AVIF",
domain: "image",
note: "AV1 image sequences in ISO BMFF with HDR/alpha profile variation.",
},
{
id: "tiff",
label: "TIFF",
domain: "image",
note: "Flexible tagged raster container with many compression and colour variants.",
},
{
id: "heic",
label: "HEIC",
domain: "image",
note: "HEVC image collections in ISO BMFF; browser decode support varies.",
},
{
id: "wav",
label: "WAV",
@@ -170,6 +212,30 @@ export const formats: FormatDefinition[] = [
domain: "av",
note: "Flexible multi-track container with attachments and chapters.",
},
{
id: "ogg",
label: "Ogg",
domain: "av",
note: "Page-oriented container; actual preservation depends on codec streams and chaining.",
},
{
id: "m4a",
label: "M4A",
domain: "av",
note: "ISO BMFF audio convention, commonly AAC or ALAC with metadata.",
},
{
id: "mov",
label: "QuickTime MOV",
domain: "av",
note: "QuickTime/ISO BMFF family with broad codec and metadata options.",
},
{
id: "aac",
label: "AAC (ADTS)",
domain: "av",
note: "Raw framed lossy audio with limited document-level metadata.",
},
{
id: "srt",
label: "SRT",
@@ -194,6 +260,18 @@ export const formats: FormatDefinition[] = [
domain: "subtitle",
note: "XML timing and styling profiles vary by ecosystem.",
},
{
id: "sbv",
label: "SBV",
domain: "subtitle",
note: "Simple timestamp pairs and text blocks with minimal styling.",
},
{
id: "sami",
label: "SAMI",
domain: "subtitle",
note: "HTML-like timed captions with CSS and legacy parser variation.",
},
];
function statuses(
+68
View File
@@ -0,0 +1,68 @@
import { stableStringify } from "@add-ideas/toolbox-helpers";
export interface CorpusFile {
path: string;
mediaType: string;
purpose: string;
content: string;
}
export const FORMAT_LAB_CORPUS: readonly CorpusFile[] = [
{
path: "structured/typed.json",
mediaType: "application/json",
purpose: "Scalar types, null, nesting, Unicode and formula-like text.",
content:
'[{"id":9007199254740991,"active":true,"missing":null,"nested":{"score":1.25},"text":"Café 🚲","formula":"=2+2"}]\n',
},
{
path: "structured/text-cells.csv",
mediaType: "text/csv",
purpose:
"Leading zeros, empty cells, literal null and spreadsheet formula risk.",
content: 'id,empty,literal,formula\r\n001,,null,"=2+2"\r\n',
},
{
path: "structured/records.ndjson",
mediaType: "application/x-ndjson",
purpose: "Record framing and non-ASCII text.",
content: '{"id":1,"text":"naïve"}\n{"id":2,"text":"東京"}\n',
},
{
path: "structured/typed.xml",
mediaType: "application/xml",
purpose: "Typed lab XML dialect and escaped characters.",
content:
'<?xml version="1.0" encoding="UTF-8"?>\n<records><row><field name="id" type="number">1</field><field name="text" type="string">A &amp; B</field></row></records>\n',
},
];
export function corpusManifest(): string {
return `${stableStringify(
{
schema: "https://git.add-ideas.de/lotobo/format-lab/schema/corpus-v1",
corpusVersion: 1,
generator: "de.add-ideas.format-lab",
deterministic: true,
files: FORMAT_LAB_CORPUS.map((file) => ({
path: file.path,
mediaType: file.mediaType,
purpose: file.purpose,
utf8Bytes: new TextEncoder().encode(file.content).length,
})),
},
2,
)}\n`;
}
export function exportCorpusFiles(): CorpusFile[] {
return [
...FORMAT_LAB_CORPUS.map((file) => ({ ...file })),
{
path: "corpus-manifest.json",
mediaType: "application/json",
purpose: "Machine-readable deterministic corpus inventory.",
content: corpusManifest(),
},
];
}
+37 -1
View File
@@ -5,6 +5,7 @@ import {
stringifyCsv,
} from "@add-ideas/toolbox-helpers";
import type { ConversionRoute } from "./catalog";
import { createEvidenceReport, type EvidenceReport } from "./evidence";
export type DataFormat = "json" | "ndjson" | "csv" | "xml";
export type MeasurementStatus =
@@ -29,6 +30,7 @@ export interface RoundTripResult {
roundTrip: DataDocument;
measurements: PropertyMeasurement[];
notes: string[];
evidence: EvidenceReport;
}
const MAX_SOURCE_BYTES = 2 * 1024 * 1024;
@@ -445,13 +447,47 @@ export function runRoundTrip(
(note) => `Round-trip ${sourceFormat.toUpperCase()}: ${note}`,
),
);
const measurements = measureData(original, roundTrip);
const evidence = createEvidenceReport({
generator: { id: "de.add-ideas.format-lab", version: "0.2.0" },
subject: {
kind: "format-round-trip",
name: route.formats.join(" → "),
},
operation: {
id: "structured-round-trip",
parameters: {
route: [...route.formats],
expected: { ...route.summary },
},
},
limits: {
maximumInputBytes: MAX_SOURCE_BYTES,
maximumOutputBytes: MAX_OUTPUT_BYTES,
maximumRows: MAX_ROWS,
maximumColumns: MAX_COLUMNS,
maximumValues: MAX_VALUES,
},
observations: measurements.map((measurement) => ({
id: `property:${measurement.property}`,
status:
measurement.status === "measured-preserved"
? "pass"
: measurement.status === "measured-changed"
? "change"
: "not-tested",
expectation: route.summary[measurement.property] ?? "unknown",
detail: measurement.detail,
})),
});
return {
route: route.formats,
targetText,
roundTripText,
original,
roundTrip,
measurements: measureData(original, roundTrip),
measurements,
notes: [...new Set(notes)],
evidence,
};
}
+107
View File
@@ -0,0 +1,107 @@
import { safeJsonParse, stableStringify } from "@add-ideas/toolbox-helpers";
export const EVIDENCE_SCHEMA =
"https://git.add-ideas.de/lotobo/format-lab/schema/evidence-v1" as const;
export type EvidenceStatus = "pass" | "change" | "not-tested" | "informational";
export interface EvidenceObservation {
id: string;
status: EvidenceStatus;
expectation?: string;
detail: string;
}
export interface EvidenceReport {
schema: typeof EVIDENCE_SCHEMA;
contractVersion: 1;
generator: { id: string; version: string };
subject: { kind: string; name: string; mediaType?: string };
operation: { id: string; parameters: Record<string, unknown> };
limits: Record<string, number | string | boolean>;
observations: EvidenceObservation[];
provenance: {
execution: "local-browser";
networkRequired: false;
recordedAt: string | null;
};
}
export function createEvidenceReport(
input: Omit<EvidenceReport, "schema" | "contractVersion" | "provenance"> & {
recordedAt?: string | null;
},
): EvidenceReport {
if (!input.generator.id || !input.generator.version)
throw new TypeError("Evidence generator identity is required.");
if (!input.observations.length || input.observations.length > 1_000)
throw new RangeError("Evidence reports need 11,000 observations.");
const ids = new Set<string>();
for (const observation of input.observations) {
if (!/^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$/u.test(observation.id))
throw new TypeError(`Invalid evidence observation id ${observation.id}.`);
if (ids.has(observation.id))
throw new TypeError(
`Duplicate evidence observation id ${observation.id}.`,
);
ids.add(observation.id);
}
return {
schema: EVIDENCE_SCHEMA,
contractVersion: 1,
generator: { ...input.generator },
subject: { ...input.subject },
operation: {
id: input.operation.id,
parameters: structuredClone(input.operation.parameters),
},
limits: { ...input.limits },
observations: input.observations.map((item) => ({ ...item })),
provenance: {
execution: "local-browser",
networkRequired: false,
recordedAt: input.recordedAt ?? null,
},
};
}
export function serializeEvidence(report: EvidenceReport): string {
return `${stableStringify(report, 2, {
maxTextChars: 2 * 1024 * 1024,
maxDepth: 24,
maxNodes: 20_000,
})}\n`;
}
export function parseEvidence(source: string): EvidenceReport {
const value = safeJsonParse(source, {
maxTextChars: 2 * 1024 * 1024,
maxDepth: 24,
maxNodes: 20_000,
rejectDangerousKeys: true,
}) as unknown;
if (!value || Array.isArray(value) || typeof value !== "object")
throw new TypeError("Evidence must be a JSON object.");
const candidate = value as Partial<EvidenceReport>;
if (
candidate.schema !== EVIDENCE_SCHEMA ||
candidate.contractVersion !== 1 ||
!candidate.generator ||
!candidate.subject ||
!candidate.operation ||
!candidate.limits ||
!Array.isArray(candidate.observations) ||
!candidate.provenance ||
candidate.provenance.execution !== "local-browser" ||
candidate.provenance.networkRequired !== false
)
throw new TypeError("Unsupported or incomplete format evidence contract.");
return createEvidenceReport({
generator: candidate.generator,
subject: candidate.subject,
operation: candidate.operation,
limits: candidate.limits,
observations: candidate.observations,
recordedAt: candidate.provenance.recordedAt,
});
}
+290
View File
@@ -0,0 +1,290 @@
export interface FormatSignature {
id: string;
format: string;
label: string;
extensions: string[];
mediaTypes: string[];
offset: number;
bytes: readonly number[];
mask?: readonly number[];
confidence: "strong" | "container" | "heuristic";
note: string;
}
const signature = (
value: Omit<FormatSignature, "id"> & { id?: string },
): FormatSignature => ({
id: value.id ?? `${value.format}@${value.offset}`,
...value,
});
/** Byte signatures are identification hints, never a validation claim. */
export const formatSignatures: readonly FormatSignature[] = [
signature({
format: "png",
label: "PNG",
extensions: ["png"],
mediaTypes: ["image/png"],
offset: 0,
bytes: [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a],
confidence: "strong",
note: "Complete PNG signature.",
}),
signature({
format: "jpeg",
label: "JPEG",
extensions: ["jpg", "jpeg"],
mediaTypes: ["image/jpeg"],
offset: 0,
bytes: [0xff, 0xd8, 0xff],
confidence: "strong",
note: "Start-of-image plus following marker prefix.",
}),
signature({
format: "gif",
label: "GIF87a",
extensions: ["gif"],
mediaTypes: ["image/gif"],
offset: 0,
bytes: [0x47, 0x49, 0x46, 0x38, 0x37, 0x61],
confidence: "strong",
note: "GIF87a header.",
}),
signature({
id: "gif89a@0",
format: "gif",
label: "GIF89a",
extensions: ["gif"],
mediaTypes: ["image/gif"],
offset: 0,
bytes: [0x47, 0x49, 0x46, 0x38, 0x39, 0x61],
confidence: "strong",
note: "GIF89a header.",
}),
signature({
format: "webp",
label: "WebP",
extensions: ["webp"],
mediaTypes: ["image/webp"],
offset: 0,
bytes: [0x52, 0x49, 0x46, 0x46, 0, 0, 0, 0, 0x57, 0x45, 0x42, 0x50],
mask: [255, 255, 255, 255, 0, 0, 0, 0, 255, 255, 255, 255],
confidence: "strong",
note: "RIFF with WEBP form type; length bytes masked.",
}),
signature({
format: "tiff-le",
label: "TIFF little-endian",
extensions: ["tif", "tiff"],
mediaTypes: ["image/tiff"],
offset: 0,
bytes: [0x49, 0x49, 0x2a, 0x00],
confidence: "strong",
note: "Classic little-endian TIFF.",
}),
signature({
format: "tiff-be",
label: "TIFF big-endian",
extensions: ["tif", "tiff"],
mediaTypes: ["image/tiff"],
offset: 0,
bytes: [0x4d, 0x4d, 0x00, 0x2a],
confidence: "strong",
note: "Classic big-endian TIFF.",
}),
signature({
format: "bmp",
label: "BMP",
extensions: ["bmp"],
mediaTypes: ["image/bmp"],
offset: 0,
bytes: [0x42, 0x4d],
confidence: "heuristic",
note: "Bitmap file marker; structural validation still required.",
}),
signature({
format: "pdf",
label: "PDF",
extensions: ["pdf"],
mediaTypes: ["application/pdf"],
offset: 0,
bytes: [0x25, 0x50, 0x44, 0x46, 0x2d],
confidence: "strong",
note: "%PDF- header.",
}),
signature({
format: "zip",
label: "ZIP-compatible container",
extensions: ["zip", "docx", "xlsx", "pptx", "epub", "odt", "ods", "odp"],
mediaTypes: ["application/zip"],
offset: 0,
bytes: [0x50, 0x4b, 0x03, 0x04],
confidence: "container",
note: "Container only; entries distinguish ZIP-based formats.",
}),
signature({
id: "zip-empty@0",
format: "zip",
label: "Empty ZIP",
extensions: ["zip"],
mediaTypes: ["application/zip"],
offset: 0,
bytes: [0x50, 0x4b, 0x05, 0x06],
confidence: "container",
note: "Empty archive end record.",
}),
signature({
format: "gzip",
label: "Gzip",
extensions: ["gz", "tgz"],
mediaTypes: ["application/gzip"],
offset: 0,
bytes: [0x1f, 0x8b, 0x08],
confidence: "strong",
note: "Gzip header with deflate method.",
}),
signature({
format: "bzip2",
label: "Bzip2",
extensions: ["bz2"],
mediaTypes: ["application/x-bzip2"],
offset: 0,
bytes: [0x42, 0x5a, 0x68],
confidence: "strong",
note: "BZip2 stream prefix.",
}),
signature({
format: "xz",
label: "XZ",
extensions: ["xz"],
mediaTypes: ["application/x-xz"],
offset: 0,
bytes: [0xfd, 0x37, 0x7a, 0x58, 0x5a, 0x00],
confidence: "strong",
note: "XZ stream header.",
}),
signature({
format: "7z",
label: "7-Zip",
extensions: ["7z"],
mediaTypes: ["application/x-7z-compressed"],
offset: 0,
bytes: [0x37, 0x7a, 0xbc, 0xaf, 0x27, 0x1c],
confidence: "strong",
note: "7z signature.",
}),
signature({
format: "rar4",
label: "RAR 4",
extensions: ["rar"],
mediaTypes: ["application/vnd.rar"],
offset: 0,
bytes: [0x52, 0x61, 0x72, 0x21, 0x1a, 0x07, 0x00],
confidence: "strong",
note: "RAR 1.54.x signature.",
}),
signature({
format: "rar5",
label: "RAR 5",
extensions: ["rar"],
mediaTypes: ["application/vnd.rar"],
offset: 0,
bytes: [0x52, 0x61, 0x72, 0x21, 0x1a, 0x07, 0x01, 0x00],
confidence: "strong",
note: "RAR 5 signature.",
}),
signature({
format: "wasm",
label: "WebAssembly",
extensions: ["wasm"],
mediaTypes: ["application/wasm"],
offset: 0,
bytes: [0x00, 0x61, 0x73, 0x6d],
confidence: "strong",
note: "WebAssembly magic; version follows.",
}),
signature({
format: "elf",
label: "ELF",
extensions: [],
mediaTypes: ["application/x-elf"],
offset: 0,
bytes: [0x7f, 0x45, 0x4c, 0x46],
confidence: "strong",
note: "ELF identification.",
}),
signature({
format: "sqlite",
label: "SQLite 3",
extensions: ["sqlite", "db"],
mediaTypes: ["application/vnd.sqlite3"],
offset: 0,
bytes: [
0x53, 0x51, 0x4c, 0x69, 0x74, 0x65, 0x20, 0x66, 0x6f, 0x72, 0x6d, 0x61,
0x74, 0x20, 0x33, 0x00,
],
confidence: "strong",
note: "SQLite 3 database header.",
}),
signature({
format: "flac",
label: "FLAC",
extensions: ["flac"],
mediaTypes: ["audio/flac"],
offset: 0,
bytes: [0x66, 0x4c, 0x61, 0x43],
confidence: "strong",
note: "Native FLAC marker.",
}),
signature({
format: "ogg",
label: "Ogg",
extensions: ["ogg", "oga", "ogv", "opus"],
mediaTypes: ["application/ogg"],
offset: 0,
bytes: [0x4f, 0x67, 0x67, 0x53],
confidence: "container",
note: "Ogg page marker; codec requires packet inspection.",
}),
signature({
format: "wav",
label: "WAVE",
extensions: ["wav"],
mediaTypes: ["audio/wav"],
offset: 0,
bytes: [0x52, 0x49, 0x46, 0x46, 0, 0, 0, 0, 0x57, 0x41, 0x56, 0x45],
mask: [255, 255, 255, 255, 0, 0, 0, 0, 255, 255, 255, 255],
confidence: "container",
note: "RIFF with WAVE form type.",
}),
signature({
format: "matroska",
label: "Matroska/WebM",
extensions: ["mkv", "mka", "webm"],
mediaTypes: ["video/x-matroska", "video/webm"],
offset: 0,
bytes: [0x1a, 0x45, 0xdf, 0xa3],
confidence: "container",
note: "EBML header; DocType distinguishes Matroska/WebM.",
}),
signature({
format: "iso-bmff",
label: "ISO Base Media",
extensions: ["mp4", "m4a", "mov", "avif", "heic"],
mediaTypes: ["video/mp4"],
offset: 4,
bytes: [0x66, 0x74, 0x79, 0x70],
confidence: "container",
note: "ftyp box marker; compatible brands distinguish formats.",
}),
];
export function identifyBySignature(bytes: Uint8Array): FormatSignature[] {
return formatSignatures.filter((item) =>
item.bytes.every((expected, index) => {
const actual = bytes[item.offset + index];
const mask = item.mask?.[index] ?? 0xff;
return actual !== undefined && (actual & mask) === (expected & mask);
}),
);
}
+8
View File
@@ -531,6 +531,12 @@ tbody tr:last-child > * {
gap: 0.75rem;
}
.selection-grid > *,
.explorer-grid > *,
.lab-grid > * {
min-width: 0;
}
.selection-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
margin-bottom: 0.85rem;
@@ -642,10 +648,12 @@ tbody tr:last-child > * {
.route-statuses small,
.status {
max-width: 100%;
border-radius: 999px;
padding: 0.22rem 0.42rem;
font-size: 0.7rem;
font-weight: 750;
overflow-wrap: anywhere;
}
.expected-preserved,
+43 -1
View File
@@ -3,7 +3,7 @@
"schemaVersion": 1,
"id": "de.add-ideas.format-lab",
"name": "Format Lab",
"version": "0.1.0",
"version": "0.2.0",
"description": "Explore conversion paths and measure information loss locally.",
"entry": "./",
"icon": "./favicon.svg",
@@ -28,6 +28,48 @@
"crossOriginIsolated": false,
"topLevelContext": false
},
"io": {
"accepts": [
{
"mediaType": "application/json",
"extensions": [".json"]
},
{
"mediaType": "application/x-ndjson",
"extensions": [".ndjson"]
},
{
"mediaType": "text/csv",
"extensions": [".csv"]
},
{
"mediaType": "application/xml",
"extensions": [".xml"]
}
],
"produces": [
{
"mediaType": "application/json",
"extensions": [".json"]
},
{
"mediaType": "application/x-ndjson",
"extensions": [".ndjson"]
},
{
"mediaType": "text/csv",
"extensions": [".csv"]
},
{
"mediaType": "application/xml",
"extensions": [".xml"]
}
]
},
"capabilities": {
"required": [],
"optional": []
},
"privacy": {
"processing": "local",
"fileUploads": true,
+1 -1
View File
@@ -1 +1 @@
export const APP_VERSION = "0.1.0";
export const APP_VERSION = "0.2.0";
+20 -1
View File
@@ -83,6 +83,25 @@ test("shows CSV losses and retains evidence after invalid source", async ({
await expect(output).toHaveValue(previous);
});
test("identifies bounded signatures and exposes the portable evidence contract", async ({
page,
}) => {
await page.goto("/deep/nested/format-lab/");
await page
.locator('input[type="file"]')
.first()
.setInputFiles("tests/fixtures/signature.pdf");
await expect(page.getByText(/PDF \(strong: %PDF- header/u)).toBeVisible();
await page.getByText("Portable evidence JSON", { exact: true }).click();
const evidence = await page
.getByRole("textbox", { name: "Evidence report", exact: true })
.inputValue();
expect(JSON.parse(evidence)).toMatchObject({
contractVersion: 1,
provenance: { execution: "local-browser", networkRequired: false },
});
});
test("integrates help, themes, identity, headers and offline reload", async ({
page,
request,
@@ -115,7 +134,7 @@ test("integrates help, themes, identity, headers and offline reload", async ({
);
await expect(manifest.json()).resolves.toMatchObject({
id: "de.add-ideas.format-lab",
version: "0.1.0",
version: "0.2.0",
privacy: { processing: "local", telemetry: false },
});
await context.setOffline(true);
+18
View File
@@ -0,0 +1,18 @@
import { expect, test } from "@playwright/test";
test("keeps the primary workspace inside a narrow viewport", async ({
page,
}) => {
await page.goto("/deep/nested/format-lab/");
await expect(page.locator("main").first()).toBeVisible();
await expect(
page.locator("main .loading, main .workbench-loading"),
).toHaveCount(0);
const widths = await page.evaluate(() => ({
content: document.documentElement.scrollWidth,
viewport: document.documentElement.clientWidth,
}));
expect(widths.viewport).toBeLessThanOrEqual(430);
expect(widths.content).toBeLessThanOrEqual(widths.viewport + 1);
});
+1
View File
@@ -0,0 +1 @@
%PDF-1.7
+8
View File
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import { findRoutes, properties } from "../../src/lab/catalog";
import { parseData, runRoundTrip, serializeData } from "../../src/lab/data";
import { parseEvidence, serializeEvidence } from "../../src/lab/evidence";
const all = properties.data.map((property) => property.id);
const sample = JSON.stringify([
@@ -29,6 +30,13 @@ describe("structured data lab", () => {
property: "metadata",
status: "not-tested",
});
expect(parseEvidence(serializeEvidence(result.evidence))).toEqual(
result.evidence,
);
expect(result.evidence).toMatchObject({
contractVersion: 1,
provenance: { execution: "local-browser", networkRequired: false },
});
});
it("exposes CSV type, null and nested-value changes", () => {
+47
View File
@@ -0,0 +1,47 @@
import { describe, expect, it } from "vitest";
import { exportCorpusFiles } from "../../src/lab/corpus";
import {
formatSignatures,
identifyBySignature,
} from "../../src/lab/signatures";
describe("format signatures and corpus", () => {
it("distinguishes masked RIFF forms and container-only evidence", () => {
const wav = Uint8Array.of(
0x52,
0x49,
0x46,
0x46,
0x24,
0,
0,
0,
0x57,
0x41,
0x56,
0x45,
);
expect(identifyBySignature(wav).map((item) => item.format)).toContain(
"wav",
);
expect(identifyBySignature(wav).map((item) => item.format)).not.toContain(
"webp",
);
expect(
identifyBySignature(Uint8Array.of(0x50, 0x4b, 0x03, 0x04))[0],
).toMatchObject({ format: "zip", confidence: "container" });
});
it("ships a broad, deterministic structured regression corpus", () => {
expect(formatSignatures.length).toBeGreaterThanOrEqual(20);
const first = exportCorpusFiles();
expect(first).toEqual(exportCorpusFiles());
expect(first.map((file) => file.path)).toEqual(
expect.arrayContaining([
"structured/typed.json",
"structured/text-cells.csv",
"corpus-manifest.json",
]),
);
});
});