feat: publish JPW7 web editor

This commit is contained in:
2026-09-22 16:33:35 +08:00
commit cacbabea85
54 changed files with 11770 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
import { cp, mkdir } from "node:fs/promises";
await mkdir("dist", { recursive: true });
await cp("index.html", "dist/index.html");
await cp("src/app/styles.css", "dist/styles.css");
+82
View File
@@ -0,0 +1,82 @@
import { readdir, readFile, writeFile } from "node:fs/promises";
import { createHash } from "node:crypto";
import { basename, join, relative } from "node:path";
const rawRoot = "research/jpw7/demo";
const decodedRoot = "research/jpw7/decoded";
const manifestPath = "fixtures/manifest.json";
const sha256 = (bytes) => createHash("sha256").update(bytes).digest("hex");
const sorted = (values) => [...values].sort((left, right) => left.localeCompare(right, "zh-Hans-CN"));
const walk = async (root, prefix = "") => {
const entries = await readdir(join(root, prefix), { withFileTypes: true });
const paths = [];
for (const entry of entries) {
const child = join(prefix, entry.name);
if (entry.isDirectory()) paths.push(...await walk(root, child));
else paths.push(child);
}
return paths;
};
const rawPaths = sorted((await walk(rawRoot)).filter((path) => path.toLowerCase().endsWith(".jpwabc")));
const samples = [];
for (const rawRelativePath of rawPaths) {
const rawName = basename(rawRelativePath);
const candidates = [
`${rawName}.txt`,
`${rawName.replace(/\.jpwabc$/i, "")}.txt`,
`${rawRelativePath}.txt`,
rawName
];
let decodedName;
for (const candidate of candidates) {
try {
await readFile(join(decodedRoot, candidate));
decodedName = candidate;
break;
} catch {
// Try the next conventional decoded filename.
}
}
if (!decodedName) {
throw new Error(`No decoded pair found for ${rawName}`);
}
const rawPath = join(rawRoot, rawRelativePath);
const decodedPath = join(decodedRoot, decodedName);
const rawBytes = await readFile(rawPath);
const decodedBytes = await readFile(decodedPath);
samples.push({
id: rawRelativePath.replace(/\.jpwabc$/i, "").replaceAll("/", "::"),
sourceReadOnly: true,
rawPath: relative(".", rawPath),
decodedPath: relative(".", decodedPath),
rawBytes: rawBytes.byteLength,
decodedBytes: decodedBytes.byteLength,
rawSha256: sha256(rawBytes),
decodedSha256: sha256(decodedBytes),
classification: {
status: "pending",
firstResponsibleMilestone: "M1",
expected: [
"encoding-and-bom",
"section-inventory",
"core-semantics",
"unknown-content-retained",
"diagnostics-emitted"
]
}
});
}
const manifest = {
manifestVersion: 1,
purpose: "M0 read-only fixture baseline",
sampleCount: samples.length,
generatedBy: "scripts/generate-fixture-manifest.mjs",
sourceRoots: { raw: rawRoot, decoded: decodedRoot },
samples
};
await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
console.log(`Wrote ${manifestPath} with ${samples.length} sample pairs`);
+43
View File
@@ -0,0 +1,43 @@
import { mkdir, writeFile } from "node:fs/promises";
const range = (prefix, start, end) => Array.from({ length: end - start + 1 }, (_, index) => `${prefix}-${String(start + index).padStart(3, "0")}`);
const rows = [];
const add = ({ ids, priority, stages, accRefs, source, scope = "R1", notes }) => {
for (const id of ids) rows.push({ id, source, priority, scope, stages, accRefs, status: "baseline-not-started", notes });
};
add({ ids: range("F", 1, 7), source: "PRD-F-001..007", priority: "P0", stages: ["M1", "M2", "M4", "M5"], accRefs: ["ACC-F-001..020", "ACC-X-001..012"], notes: "核心文件、导入、编辑、工程和导出闭环;在实现阶段拆解为逐条断言。" });
add({ ids: ["F-008"], source: "PRD-F-008", priority: "P1", stages: ["M2", "M4", "M6", "M7"], accRefs: ["ACC-F-021..022", "ACC-L-009..011", "ACC-L-019", "ACC-X-013"], notes: "扩展编辑能力,未知内容必须保留并诊断。" });
add({ ids: range("E", 1, 4), source: "PRD-E-001..004", priority: "P0", stages: ["M2", "M4", "M6"], accRefs: ["ACC-E-001..004", "ACC-E-007", "ACC-F-009"], notes: "核心节拍、音符、休止和时值语义。" });
add({ ids: range("E", 5, 8), source: "PRD-E-005..008", priority: "P1", stages: ["M6", "M8"], accRefs: ["ACC-E-005..012", "ACC-O-001..006", "ACC-A-009", "ACC-A-017", "ACC-A-021..023"], notes: "复杂装饰、和弦/伴奏及扩展语义。" });
add({ ids: range("T", 1, 6), source: "PRD-T-001..006", priority: "P0/P1", stages: ["M4", "M6"], accRefs: ["ACC-T-001..007", "ACC-L-006", "ACC-L-012"], notes: "歌词、分词、对齐和未知歌词内容保留。" });
add({ ids: range("V", 1, 6), source: "PRD-V-001..006", priority: "P1", stages: ["M7"], accRefs: ["ACC-V-001..006", "ACC-O-003..006", "ACC-L-020"], notes: "多声部、声部组和临时声部。" });
add({ ids: ["V-007"], source: "PRD-V-007", priority: "P1", stages: ["M5", "M7"], accRefs: ["ACC-V-007", "ACC-A-015"], notes: "声部级播放/静音与导出边界。" });
add({ ids: range("L", 1, 6), source: "PRD-L-001..006", priority: "P0/P1", stages: ["M3", "M6"], accRefs: ["ACC-L-001..008", "ACC-L-013..018", "ACC-L-020", "ACC-G-001..004", "ACC-X-009"], notes: "A4 固定布局、分页、字体测量和 SVG 正式谱面。" });
add({ ids: range("L", 7, 8), source: "PRD-L-007..008", priority: "P1", stages: ["M7"], accRefs: ["ACC-L-009..011", "ACC-L-019"], notes: "手动布局状态必须进入工程保存。" });
add({ ids: ["L-009"], source: "PRD-L-009", priority: "P1", stages: ["M3", "M6"], accRefs: ["ACC-L-005", "ACC-L-015", "ACC-G-001..004"], notes: "复杂布局附件和占位行为。" });
add({ ids: ["L-010"], source: "PRD-L-010", priority: "P1", stages: ["M3"], accRefs: ["ACC-L-018", "ACC-Q-002"], notes: "输出确定性和溢出诊断。" });
add({ ids: [...range("A", 1, 3), ...range("A", 6, 7)], source: "PRD-A-001..003,006..007", priority: "P0", stages: ["M5"], accRefs: ["ACC-A-001..008", "ACC-A-012..016"], notes: "基础播放、控制、时间线和声部控制。" });
add({ ids: range("A", 4, 5), source: "PRD-A-004..005", priority: "P1", stages: ["M8"], accRefs: ["ACC-A-009..011", "ACC-A-017..023"], notes: "反复、谱内控制状态和扩展播放降级。" });
add({ ids: ["A-008"], source: "PRD-A-008", priority: "P2", scope: "out-of-scope", stages: [], accRefs: ["ACC-A-023"], notes: "外部音色/扩展音频能力明确不纳入本目标。" });
add({ ids: ["A-009"], source: "PRD-A-009", priority: "P1", stages: ["M8"], accRefs: ["ACC-A-006", "ACC-A-018", "ACC-A-022", "ACC-E-007", "ACC-T-007"], notes: "W1 播放金样和控制语义。" });
add({ ids: range("U", 1, 4), source: "PRD-U-001..004", priority: "P0", stages: ["M4", "M5", "M9"], accRefs: ["ACC-Q-004..006", "ACC-L-013", "ACC-X-003", "ACC-X-006"], notes: "可用性、键盘、无障碍和错误提示。" });
add({ ids: range("Q", 1, 9), source: "验收标准-质量门槛", priority: "P0/P1", stages: ["M0", "M5", "M9"], accRefs: ["ACC-Q-001..009", "ACC-X-001..014"], notes: "性能、安全、回退、证据完整性和发布门槛;逐项结果在验收证据包中记录。" });
await mkdir("config", { recursive: true });
const baseline = {
version: 1,
generatedBy: "scripts/generate-requirements.mjs",
sources: [
{ path: "docs/JPW7在线简谱编辑器-产品需求文档.md", version: "current project baseline" },
{ path: "docs/JPW7在线简谱编辑器-功能规格与技术方案.md", version: "current project baseline" },
{ path: "docs/JPW7在线简谱编辑器-开发步骤计划.md", version: "v1.1" },
{ path: "docs/JPW7在线简谱编辑器-验收标准.md", version: "v1.2" },
{ path: "docs/JPW7在线简谱编辑器-兼容性矩阵.md", version: "current project baseline" }
],
capabilityDimensions: ["file-codec", "parser", "lossless-retention", "semantic-model", "stable-anchor", "commands", "project-save", "layout", "svg", "playback", "compatibility-export", "e2e", "performance", "security", "accessibility", "licensing", "evidence"],
entryCount: rows.length,
entries: rows
};
await writeFile("config/requirements.json", `${JSON.stringify(baseline, null, 2)}\n`, "utf8");
console.log(`Wrote config/requirements.json with ${rows.length} traceability entries`);
+107
View File
@@ -0,0 +1,107 @@
import { access, readFile } from "node:fs/promises";
import { createHash } from "node:crypto";
const failures = [];
const check = (condition, message) => {
if (!condition) failures.push(message);
};
const loadJson = async (path) => JSON.parse(await readFile(path, "utf8"));
const sha256 = (bytes) => createHash("sha256").update(bytes).digest("hex");
const exists = async (path) => {
try {
await access(path);
return true;
} catch {
return false;
}
};
const packageJson = await loadJson("package.json");
for (const script of ["build", "test", "check", "dev", "fixtures:manifest", "baseline:check"]) {
check(typeof packageJson.scripts?.[script] === "string", `package.json is missing script: ${script}`);
}
check(packageJson.type === "module", "package.json must use native ESM");
check(packageJson.devDependencies?.typescript === "5.9.3", "TypeScript version must remain pinned at 5.9.3 for M0");
const manifest = await loadJson("fixtures/manifest.json");
check(manifest.manifestVersion === 1, "fixture manifest version must be 1");
check(manifest.sampleCount === 33, `fixture manifest must contain 33 pairs, got ${manifest.sampleCount}`);
check(manifest.samples?.length === 33, "fixture manifest sample array must contain 33 entries");
const ids = new Set();
for (const sample of manifest.samples ?? []) {
check(!ids.has(sample.id), `duplicate fixture id: ${sample.id}`);
ids.add(sample.id);
check(sample.sourceReadOnly === true, `fixture is not read-only: ${sample.id}`);
check(sample.rawPath.startsWith("research/"), `raw fixture escapes research root: ${sample.id}`);
check(sample.decodedPath.startsWith("research/"), `decoded fixture escapes research root: ${sample.id}`);
check(sample.rawPath !== sample.decodedPath, `raw/decoded fixture paths must differ: ${sample.id}`);
const rawBytes = await readFile(sample.rawPath);
const decodedBytes = await readFile(sample.decodedPath);
check(rawBytes.byteLength === sample.rawBytes, `raw byte count drifted: ${sample.id}`);
check(decodedBytes.byteLength === sample.decodedBytes, `decoded byte count drifted: ${sample.id}`);
check(sha256(rawBytes) === sample.rawSha256, `raw SHA-256 drifted: ${sample.id}`);
check(sha256(decodedBytes) === sample.decodedSha256, `decoded SHA-256 drifted: ${sample.id}`);
}
const researchBaseline = await loadJson("config/research-baseline.json");
check(researchBaseline.readOnly === true, "research baseline must be read-only");
check(researchBaseline.observedInventory.samplePairCount === manifest.sampleCount, "research and fixture sample counts differ");
check(researchBaseline.observedInventory.majorSectionKinds === 8, "M0 section-kind baseline must remain 8");
check(researchBaseline.observedInventory.braceTokenHeadKinds === 214, "M0 token-head baseline must remain 214");
for (const sourcePath of Object.values(researchBaseline.source)) check(await exists(sourcePath), `missing research baseline source: ${sourcePath}`);
const schema = await loadJson("schema/jianpu.schema.json");
check(schema.$schema?.includes("2020-12"), "project schema must declare JSON Schema 2020-12");
check(schema.properties?.format?.const === "jianpu-web", "project schema format must be jianpu-web");
check(schema.properties?.schemaVersion?.const === 1, "project schema version must be 1");
for (const definition of ["source", "score", "section", "measure", "event", "unknownBlock", "manualLayout"]) {
check(Boolean(schema.$defs?.[definition]), `project schema missing definition: ${definition}`);
}
const requirements = await loadJson("config/requirements.json");
check(requirements.entryCount === requirements.entries?.length, "requirements entryCount is not reproducible");
const requirementIds = new Set((requirements.entries ?? []).map((entry) => entry.id));
for (const requiredId of ["F-001", "E-001", "T-001", "V-001", "L-001", "A-001", "U-001", "Q-001", "A-008"]) {
check(requirementIds.has(requiredId), `requirements traceability is missing ${requiredId}`);
}
check(requirementIds.size === requirements.entries?.length, "requirements IDs must be unique");
check(requirements.entries.every((entry) => Array.isArray(entry.stages) && Array.isArray(entry.accRefs)), "requirements entries need stage and ACC references");
check(requirements.entries.find((entry) => entry.id === "A-008")?.scope === "out-of-scope", "P2 A-008 must remain explicitly out of scope");
check(Array.isArray(requirements.sources) && requirements.sources.length >= 5, "requirements source documents are incomplete");
for (const source of requirements.sources ?? []) check(await exists(source.path), `missing requirements source document: ${source.path}`);
check(Array.isArray(requirements.capabilityDimensions) && requirements.capabilityDimensions.includes("evidence"), "requirements capability dimensions are incomplete");
const licenses = await loadJson("config/resource-licenses.json");
check(Array.isArray(licenses.entries) && licenses.entries.length >= 4, "resource license list is incomplete");
check(licenses.entries.every((entry) => entry.source && entry.license && entry.status), "resource license entries need source, license and status");
check(licenses.entries.find((entry) => entry.id === "research.jpw7-private-assets")?.distribution === "forbidden", "private research assets must be forbidden for distribution");
const writeSet = await loadJson("config/original-format-write-set.json");
check(writeSet.policy === "allow-list-only", "original-format writer must be allow-list-only");
check(writeSet.defaultWrite === "blocked", "original-format writer default must be blocked");
check(writeSet.externalReplayRequired === true, "original-format external replay gate is required");
check(writeSet.unknownContent === "preserve-and-block-unsafe-export", "unknown original content policy is unsafe-export blocking");
check(Array.isArray(writeSet.entries) && writeSet.entries.length >= 5, "original-format write set is incomplete");
const environment = await loadJson("config/acceptance-environment.json");
check(environment.baselineId === "jpw7-web-m0", "acceptance environment baseline ID drifted");
check(environment.primaryAcceptanceProfile.machine === "Windows 11 x64 desktop", "primary acceptance machine must be fixed");
check(environment.primaryAcceptanceProfile.browserProfiles.length === 3, "acceptance browser profile list must contain three profiles");
check(environment.primaryAcceptanceProfile.paperProfile.size === "A4", "acceptance paper must be A4");
check(environment.primaryAcceptanceProfile.paperProfile.printScale === "100%", "acceptance print scale must be 100%");
check(environment.status.includes("measured run evidence"), "acceptance environment must require measured run evidence");
if (failures.length) {
console.error(JSON.stringify({ ok: false, failures }, null, 2));
process.exitCode = 1;
} else {
console.log(JSON.stringify({
ok: true,
check: "M0 baseline",
samplePairs: manifest.sampleCount,
requirementEntries: requirements.entryCount,
schemaVersion: schema.properties.schemaVersion.const,
originalFormatPolicy: writeSet.policy,
acceptanceProfile: environment.baselineId
}, null, 2));
}
+131
View File
@@ -0,0 +1,131 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { basename, dirname, join } from "node:path";
import { execFileSync } from "node:child_process";
import { performance } from "node:perf_hooks";
import os from "node:os";
import { parseJpwabc } from "../dist/domain/jpwabc.js";
import { decodeProject, encodeProject } from "../dist/domain/project-codec.js";
import { modelFromParse, createEmptyDocument, createNoteEvent, rational, modelEquivalent } from "../dist/domain/score-model.js";
import { compilePlaybackTimeline } from "../dist/playback/playback.js";
import { DEFAULT_PAPER_CONFIG, layoutDocument, layoutSummary } from "../dist/layout/layout-engine.js";
import { createDefaultFontMeasurementProvider } from "../dist/layout/font-measure.js";
import { exportSnapshotSvg, buildPrintableHtml, outputGeometry } from "../dist/output/output.js";
import { probeSvg, renderSnapshotSvg } from "../dist/layout/svg-renderer.js";
const ROOT = "acceptance/m9";
const manifest = JSON.parse(await readFile("fixtures/manifest.json", "utf8"));
const environment = {
generatedAt: new Date().toISOString(),
node: process.version,
platform: `${process.platform} ${os.release()} ${os.arch()}`,
cpu: os.cpus()[0]?.model ?? "unknown",
memoryBytes: os.totalmem(),
chrome: (() => { try { return execFileSync("google-chrome", ["--version"], { encoding: "utf8" }).trim(); } catch { return "not available"; } })(),
firefox: (() => { try { return execFileSync("firefox", ["--version"], { encoding: "utf8" }).trim(); } catch { return "not available"; } })(),
project: "jianpu-web-editor",
buildCommand: "npm run build",
testCommand: "npm test",
note: "Node-side local evidence; browser, original JPW7, printer and external resource claims are listed separately and are not inferred."
};
for (const directory of [ROOT, `${ROOT}/projects`, `${ROOT}/svg`, `${ROOT}/png`, `${ROOT}/pdf`, `${ROOT}/screenshots`, `${ROOT}/native-roundtrip`]) await mkdir(directory, { recursive: true });
await writeFile(`${ROOT}/environment.json`, `${JSON.stringify(environment, null, 2)}\n`);
const importRows = [];
const layoutRows = [];
const playbackRows = [];
const exportRows = [];
const caseRows = [];
const fontProvider = createDefaultFontMeasurementProvider();
let firstSnapshot = null;
let maxPages = 0;
let preservedCount = 0;
let reopenCount = 0;
let svgCount = 0;
let blockedExportCount = 0;
for (const [index, fixture] of manifest.samples.entries()) {
const bytes = await readFile(fixture.rawPath);
const parsed = parseJpwabc(new Uint8Array(bytes));
const model = modelFromParse(parsed, { fileName: basename(fixture.rawPath) });
const projectText = encodeProject(model);
const reopened = decodeProject(projectText);
const reopenedEquivalent = modelEquivalent(model, reopened);
if (reopenedEquivalent) reopenCount += 1;
if (model.source?.rawBase64) preservedCount += 1;
const snapshot = await layoutDocument(model, DEFAULT_PAPER_CONFIG, fontProvider);
if (!firstSnapshot) firstSnapshot = snapshot;
const summary = layoutSummary(snapshot);
maxPages = Math.max(maxPages, summary.pageCount);
const timeline = compilePlaybackTimeline(model);
const safeName = `${String(index + 1).padStart(2, "0")}-${fixture.id.replace(/[^\p{L}\p{N}._-]+/gu, "_")}`;
await writeFile(`${ROOT}/projects/${safeName}.jianpu.json`, projectText);
const svgs = exportSnapshotSvg(snapshot);
for (const [pageIndex, svg] of svgs.entries()) {
await writeFile(`${ROOT}/svg/${safeName}-page-${pageIndex + 1}.svg`, svg);
svgCount += 1;
}
const probe = svgs[0] ? probeSvg(svgs[0]) : null;
importRows.push({ fixtureId: fixture.id, fixtureSha256: fixture.rawSha256, rawPath: fixture.rawPath, parsedStatus: parsed.compatibility.status, encoding: parsed.source.encoding, sections: parsed.sections.length, diagnostics: parsed.diagnostics.length, unknownTokens: parsed.unknownTokens.length, unknownBlocks: parsed.unknownBlocks.length, sourcePreserved: Boolean(model.source?.rawBase64), result: "pass-local" });
layoutRows.push({ fixtureId: fixture.id, fixtureSha256: fixture.rawSha256, summary, deterministicKey: snapshot.deterministicKey, svgProbe: probe, warnings: snapshot.warnings.length, result: "pass-local" });
playbackRows.push({ fixtureId: fixture.id, fixtureSha256: fixture.rawSha256, voiceCount: timeline.voiceIds.length, eventCount: timeline.events.length, durationTicks: timeline.durationTicks, warnings: timeline.warnings, controlEventCount: timeline.controlEvents?.length ?? 0, result: "pass-local-semantic" });
const exportStatus = model.attachments.length || model.pageObjects.length || model.unknownBlocks.length || model.voices.some((voice) => voice.temporary) ? "blocked-by-safety-preflight" : "core-preflight-evaluated";
if (exportStatus === "blocked-by-safety-preflight") blockedExportCount += 1;
exportRows.push({ fixtureId: fixture.id, fixtureSha256: fixture.rawSha256, svgPages: svgs.length, outputGeometry: outputGeometry(snapshot), svgIndependentArtifact: `${ROOT}/svg/${safeName}-page-1.svg`, printableHtmlBytes: buildPrintableHtml(snapshot).length, png: "not-executed-in-node; representative Chromium PNG remains in acceptance/m5", pdf: "not-executed-in-node; representative Chromium PDF remains in acceptance/m5", compatibilityPreflight: exportStatus, result: "pass-local-svg-and-engineering-export" });
caseRows.push({ caseId: `M9-33-${String(index + 1).padStart(2, "0")}`, fixtureId: fixture.id, fixtureSha256: fixture.rawSha256, environment: "node-local", steps: "import -> model -> project save/reopen -> fixed layout -> timeline -> SVG", expected: "source retained, complete project round-trip, deterministic A4 SVG and bounded playback diagnostics", actual: { reopenedEquivalent, pages: summary.pageCount, lines: summary.lineCount, measures: summary.measureCount, symbols: summary.symbolCount, playbackEvents: timeline.events.length }, result: reopenedEquivalent && svgs.length > 0 ? "pass-local" : "failed", evidence: [`${ROOT}/projects/${safeName}.jianpu.json`, `${ROOT}/svg/${safeName}-page-1.svg`] });
}
const malicious = `JPW-ABC File Ver 7.30\n.Voice\n<svg onload="alert(1)"> <script>alert(2)</script> 1\n`;
const maliciousModel = modelFromParse(parseJpwabc(new TextEncoder().encode(malicious)), { fileName: "malicious-input.jpwabc" });
const maliciousSnapshot = await layoutDocument(maliciousModel, DEFAULT_PAPER_CONFIG, fontProvider);
const maliciousSvg = renderSnapshotSvg(maliciousSnapshot)[0];
const unsafeTag = /<[^>]*(?:onload|onclick|javascript:)[^>]*>/i.test(maliciousSvg);
const securityRow = { caseId: "ACC-Q-008", input: "malicious source text", expected: "data only; no executable SVG/HTML attributes or external load", actual: { unsafeTag, escapedText: maliciousSvg.includes("&lt;svg") }, result: !unsafeTag && maliciousSvg.includes("&lt;svg") ? "pass-local" : "failed", evidence: `${ROOT}/security-probe.json` };
await writeFile(`${ROOT}/security-probe.json`, `${JSON.stringify(securityRow, null, 2)}\n`);
caseRows.push(securityRow);
const performanceSamples = [];
const performanceFixture = manifest.samples[0];
const performanceBytes = await readFile(performanceFixture.rawPath);
for (let run = 0; run < 20; run += 1) {
const started = performance.now();
const parsed = parseJpwabc(new Uint8Array(performanceBytes));
const model = modelFromParse(parsed);
const snapshot = await layoutDocument(model, DEFAULT_PAPER_CONFIG, fontProvider);
performanceSamples.push({ run: run + 1, milliseconds: performance.now() - started, pages: snapshot.pages.length });
}
const sortedTimes = performanceSamples.map((sample) => sample.milliseconds).sort((a, b) => a - b);
const p95 = sortedTimes[Math.min(sortedTimes.length - 1, Math.ceil(sortedTimes.length * 0.95) - 1)];
const longDocument = createEmptyDocument("m9-10-page-synthetic");
const longMeasures = [];
for (let index = 0; index < 120; index += 1) longMeasures.push({ id: `m9-measure-${index}`, ordinal: index, beats: rational(4), events: [1, 2, 3, 4].map((degree, eventIndex) => createNoteEvent(`m9-event-${index}-${eventIndex}`, degree, rational(1))) });
longDocument.voices.push({ id: "m9-voice", name: "性能合成", sectionId: null, visible: true, measures: longMeasures });
const longStarted = performance.now();
const longSnapshot = await layoutDocument(longDocument, DEFAULT_PAPER_CONFIG, fontProvider);
const longMilliseconds = performance.now() - longStarted;
await writeFile(`${ROOT}/performance.json`, `${JSON.stringify({ method: "Node local approximation; does not replace 20 cold browser runs, 100-edit P95, 10-minute audio or 30-minute resource-release tests", firstFixture: performanceFixture.id, firstDisplayRuns: performanceSamples, p95Milliseconds: p95, tenPageSynthetic: { pages: longSnapshot.pages.length, milliseconds: longMilliseconds }, targets: { firstDisplayP95Milliseconds: 2000, editP95Milliseconds: 100, tenMinutePlaybackDriftMilliseconds: 20 }, status: "local-measured; browser/device portions not-executed" }, null, 2)}\n`);
await writeFile(`${ROOT}/import-report.jsonl`, `${importRows.map((row) => JSON.stringify(row)).join("\n")}\n`);
await writeFile(`${ROOT}/layout-report.jsonl`, `${layoutRows.map((row) => JSON.stringify(row)).join("\n")}\n`);
await writeFile(`${ROOT}/playback-timeline.jsonl`, `${playbackRows.map((row) => JSON.stringify(row)).join("\n")}\n`);
await writeFile(`${ROOT}/export-report.jsonl`, `${exportRows.map((row) => JSON.stringify(row)).join("\n")}\n`);
await writeFile(`${ROOT}/case-results.jsonl`, `${caseRows.map((row) => JSON.stringify(row)).join("\n")}\n`);
let previousNativeRows = new Map();
try {
const previousNativeText = await readFile(`${ROOT}/native-roundtrip-report.jsonl`, "utf8");
previousNativeRows = new Map(previousNativeText.split(/\r?\n/).filter(Boolean).map((line) => JSON.parse(line)).filter((row) => row.result !== "not-executed").map((row) => [row.fixtureId, row]));
} catch {
// A fresh M9 run has no external native evidence to preserve.
}
const nativeRows = manifest.samples.map((fixture) => previousNativeRows.get(fixture.id) ?? ({ caseId: "ACC-X-012", fixtureId: fixture.id, fixtureSha256: fixture.rawSha256, result: "not-executed", reason: "JPW7 v7.30 original program/Windows replay is not available in this Linux Web-only run; Web round-trip is not substituted.", evidence: `${ROOT}/native-roundtrip-report.jsonl` }));
await writeFile(`${ROOT}/native-roundtrip-report.jsonl`, `${nativeRows.map((row) => JSON.stringify(row)).join("\n")}\n`);
let previousExternal = {};
try {
previousExternal = JSON.parse(await readFile(`${ROOT}/summary.json`, "utf8")).external ?? {};
} catch {
// A fresh M9 run has no previous external summary.
}
const nativeCount = nativeRows.filter((row) => row.result !== "not-executed").length;
const nativeStatus = nativeCount === 0 ? "not-executed" : nativeCount === manifest.samples.length ? `pass-${nativeCount}-of-${manifest.samples.length}` : `partial-${nativeCount}-of-${manifest.samples.length}`;
await writeFile(`${ROOT}/summary.json`, `${JSON.stringify({ sampleCount: manifest.samples.length, preservedCount, reopenCount, svgCount, maxPages, blockedExportCount, localCases: caseRows.length, security: securityRow.result, external: { nativeRoundtrip: nativeStatus, edge: "not-executed", firefox: "not-executed", printer: "not-executed", tenMinuteAudio: "not-executed", ...previousExternal, nativeRoundtrip: nativeStatus } }, null, 2)}\n`);
console.log(JSON.stringify({ samples: manifest.samples.length, preservedCount, reopenCount, svgCount, maxPages, blockedExportCount, p95Milliseconds: p95, longDocumentPages: longSnapshot.pages.length }));
+94
View File
@@ -0,0 +1,94 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { parseJpwabc, hasExactRoundTrip } from "../dist/domain/jpwabc.js";
const manifest = JSON.parse(await readFile("fixtures/manifest.json", "utf8"));
const rows = [];
const failures = [];
for (const fixture of manifest.samples) {
try {
const bytes = await readFile(fixture.rawPath);
const result = parseJpwabc(bytes);
const exactRoundTrip = hasExactRoundTrip(result);
const errors = result.diagnostics.filter((item) => item.severity === "error");
const warnings = result.diagnostics.filter((item) => item.severity === "warning");
const status = errors.length ? "recovered-error" : warnings.length || result.unknownTokens.length || result.unknownBlocks.length ? "parsed-with-diagnostics" : "parsed-clean";
const row = {
id: fixture.id,
rawPath: fixture.rawPath,
decodedPath: fixture.decodedPath,
rawSha256: fixture.rawSha256,
rawBytes: bytes.byteLength,
status,
sourcePreserved: exactRoundTrip,
encoding: result.source.encoding,
bom: result.source.bom,
formatVersion: result.header.formatVersion,
productVersion: result.header.productVersion,
sections: result.sections.map((section) => ({ name: section.name, known: section.known, lineCount: section.lines.length })),
semanticCounts: {
options: result.options.document.length,
unknownOptions: result.options.unknownDocument.length,
fonts: result.fonts.length,
titles: result.titles.length,
voiceLines: result.voices.length,
voiceTokens: result.voices.reduce((count, line) => count + line.tokens.length, 0),
wordBlocks: result.words.length,
attachments: result.attachments.length,
pages: result.pages.length
},
preservationCounts: {
rawTokens: result.rawTokens.length,
unknownTokens: result.unknownTokens.length,
unknownBlocks: result.unknownBlocks.length
},
diagnostics: result.diagnostics.map(({ code, severity, message, recovery }) => ({ code, severity, message, recovery })),
compatibility: result.compatibility
};
rows.push(row);
if (!exactRoundTrip) failures.push(`${fixture.id}: source bytes changed on no-edit serialization`);
} catch (error) {
failures.push(`${fixture.id}: parser threw ${error instanceof Error ? error.message : String(error)}`);
rows.push({ id: fixture.id, rawPath: fixture.rawPath, status: "parser-threw", sourcePreserved: false, error: String(error) });
}
}
if (rows.length !== manifest.sampleCount || rows.length !== 33) failures.push(`expected 33 classifications, got ${rows.length}`);
if (rows.some((row) => !row.status || row.sourcePreserved !== true)) failures.push("one or more samples has no status or failed source preservation");
const report = {
reportVersion: 1,
milestone: "M1",
purpose: "33-sample parse, preservation and diagnostic classification; not an external JPW7 compatibility pass",
sampleCountExpected: 33,
sampleCountActual: rows.length,
statusCounts: Object.fromEntries([...new Set(rows.map((row) => row.status))].sort().map((status) => [status, rows.filter((row) => row.status === status).length])),
allSourceBytesPreserved: failures.length === 0,
failures,
samples: rows
};
await mkdir("acceptance/m1", { recursive: true });
await writeFile("acceptance/m1/sample-classification.json", `${JSON.stringify(report, null, 2)}\n`, "utf8");
const markdown = [
"# M1 33 样例分类报告",
"",
"> 分类、诊断和保留结果是 Web parser 的实测结果,不代表 JPW7 原程序兼容通过;外部回验仍由 R0/R1 单独执行。",
"",
`- 样例:${report.sampleCountActual}/${report.sampleCountExpected}`,
`- 无编辑字节级保留:${report.allSourceBytesPreserved ? "是" : "否"}`,
`- 状态:${JSON.stringify(report.statusCounts)}`,
`- 失败:${report.failures.length}`,
"",
"| 样例 | 状态 | 编码/BOM | 区块 | 未知 token | 未知区块 | 诊断 |",
"|---|---|---|---:|---:|---:|---:|",
...rows.map((row) => `| ${row.id} | ${row.status} | ${row.encoding}/${row.bom} | ${row.sections?.length ?? 0} | ${row.preservationCounts?.unknownTokens ?? "-"} | ${row.preservationCounts?.unknownBlocks ?? "-"} | ${row.diagnostics?.length ?? "-"} |`),
"",
"## 解释边界",
"",
"- `parsed-with-diagnostics` 表示语义/未知内容被保留并有报告,不是全量语法支持声明。",
"- `recovered-error` 表示解析器发现错误但保留了原始字节;安全兼容导出必须阻止。",
"- `sourcePreserved` 仅证明无编辑序列化稳定,不替代 JPW7 原程序回验。",
""
].join("\n");
await writeFile("acceptance/m1/sample-classification.md", markdown, "utf8");
console.log(JSON.stringify({ ok: failures.length === 0, samples: rows.length, statusCounts: report.statusCounts, failures: failures.length }, null, 2));
if (failures.length) process.exitCode = 1;
+30
View File
@@ -0,0 +1,30 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { parseJpwabc } from "../dist/domain/jpwabc.js";
import { modelFromParse } from "../dist/domain/score-model.js";
import { DeterministicFontMeasurementProvider } from "../dist/layout/font-measure.js";
import { DEFAULT_PAPER_CONFIG, layoutDocument, layoutSummary } from "../dist/layout/layout-engine.js";
import { probeSvg, renderSnapshotSvg } from "../dist/layout/svg-renderer.js";
const raw = await readFile("research/jpw7/demo/JP-Word练习曲-歌曲-康定情歌.jpwabc");
const model = modelFromParse(parseJpwabc(raw), { fileName: "康定情歌.jpwabc" });
const snapshot = await layoutDocument(model, DEFAULT_PAPER_CONFIG, new DeterministicFontMeasurementProvider());
const [svg] = renderSnapshotSvg(snapshot);
const summary = layoutSummary(snapshot);
const probe = probeSvg(svg);
const report = {
reportVersion: 1,
milestone: "M3",
input: "research/jpw7/demo/JP-Word练习曲-歌曲-康定情歌.jpwabc",
expected: { pageCount: 1, lineCount: 6, measureCount: 16, pageWidthMm: 210, pageHeightMm: 297, viewBox: "0 0 21000 29700" },
actual: { ...summary, pageWidthMm: snapshot.pages[0]?.widthMm ?? null, pageHeightMm: snapshot.pages[0]?.heightMm ?? null, viewBox: probe.viewBox, deterministicKey: snapshot.deterministicKey },
fontGate: { provider: "DeterministicFontMeasurementProvider", measurements: snapshot.fontMeasurements.length, allLoaded: snapshot.fontMeasurements.every((measurement) => measurement.loaded), fallbackCount: snapshot.fontMeasurements.filter((measurement) => measurement.fallback).length },
warningsByCode: Object.fromEntries([...new Set(snapshot.warnings.map((warning) => warning.code))].sort().map((code) => [code, snapshot.warnings.filter((warning) => warning.code === code).length])),
svgProbe: probe,
referenceBoundary: "16 measures/1 page/6 lines are the structural baseline; Web symbol count and unknown JPW object geometry are not claimed pixel-equivalent.",
evidencePaths: ["src/layout/layout-engine.ts", "src/layout/font-measure.ts", "src/layout/svg-renderer.ts", "acceptance/m3/kangding-page-1.svg"]
};
if (summary.pageCount !== 1 || summary.lineCount !== 6 || summary.measureCount !== 16 || probe.viewBox !== "0 0 21000 29700") throw new Error(`M3 baseline mismatch: ${JSON.stringify({ summary, probe })}`);
await mkdir("acceptance/m3", { recursive: true });
await writeFile("acceptance/m3/kangding-layout-summary.json", `${JSON.stringify(report, null, 2)}\n`, "utf8");
await writeFile("acceptance/m3/kangding-page-1.svg", svg, "utf8");
console.log(JSON.stringify({ ok: true, summary, deterministicKey: snapshot.deterministicKey, svg: probe }, null, 2));
+36
View File
@@ -0,0 +1,36 @@
import { createReadStream } from "node:fs";
import { stat } from "node:fs/promises";
import { createServer } from "node:http";
import { extname, join, normalize, relative, resolve } from "node:path";
const root = resolve("dist");
const port = Number(process.env.PORT ?? 4173);
const contentTypes = {
".html": "text/html; charset=utf-8",
".js": "text/javascript; charset=utf-8",
".json": "application/json; charset=utf-8",
".css": "text/css; charset=utf-8"
};
const server = createServer(async (request, response) => {
const requestPath = decodeURIComponent((request.url ?? "/").split("?", 1)[0]);
const candidate = resolve(join(root, requestPath === "/" ? "index.html" : requestPath));
const safeRelativePath = relative(root, candidate);
if (safeRelativePath.startsWith("..") || safeRelativePath.includes(`..${process.platform === "win32" ? "\\" : "/"}`)) {
response.writeHead(403).end("Forbidden");
return;
}
try {
const file = await stat(candidate);
if (!file.isFile()) throw new Error("not a file");
response.writeHead(200, { "content-type": contentTypes[extname(candidate)] ?? "application/octet-stream" });
createReadStream(candidate).pipe(response);
} catch {
response.writeHead(404).end("Not found");
}
});
server.listen(port, "127.0.0.1", () => {
console.log(`JPW7 M0 server listening on http://127.0.0.1:${port}`);
});