initial commit

This commit is contained in:
2026-09-21 13:41:40 +09:00
commit 855c7328df
411 changed files with 85352 additions and 0 deletions
+335
View File
@@ -0,0 +1,335 @@
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src 'self' file:; style-src 'unsafe-inline'; script-src 'unsafe-inline'; connect-src 'none'; font-src 'none'; base-uri 'none'; form-action 'none'">
<title>DocView PDF 比較レビュー</title>
<style>
:root { color-scheme: light; font-family: system-ui, sans-serif; color: #202a35; background: #f4f5f7; }
* { box-sizing: border-box; }
body { margin: 0; }
header, main { max-width: 1600px; margin: auto; padding: 20px 24px; }
header { padding-bottom: 8px; }
h1 { font-size: 22px; margin: 0 0 8px; }
h2 { font-size: 18px; margin: 0; }
p { margin: 8px 0; line-height: 1.65; }
button, select, input, textarea { font: inherit; }
button, select, input[type="text"] { min-height: 38px; border: 1px solid #a9b3bd; border-radius: 4px; background: white; color: inherit; padding: 7px 10px; }
button { cursor: pointer; }
button:hover:not(:disabled) { background: #edf3fa; border-color: #396383; }
button:disabled { cursor: default; opacity: .45; }
:focus-visible { outline: 3px solid #287cbb; outline-offset: 3px; }
a { color: #175883; text-underline-offset: 3px; }
code { overflow-wrap: anywhere; font-size: 12px; }
.notice { padding: 10px 14px; background: #fff8df; border-left: 4px solid #a97816; }
.muted { color: #566575; font-size: 13px; }
.row { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
.spread { justify-content: space-between; }
.control { display: flex; align-items: center; gap: 8px; }
.panel { background: white; border: 1px solid #ced4db; border-radius: 6px; padding: 16px; margin-bottom: 16px; }
.case-control { flex: 1 1 380px; }
.case-control select { width: 100%; min-width: 0; }
#caseCounter { min-width: 58px; text-align: center; }
#reviewer { width: 220px; max-width: 100%; }
#save { background: #1e5b82; border-color: #1e5b82; color: white; }
#save:hover { background: #194b6b; }
#summary { margin: 10px 0 0; }
.toolbar { margin: 14px 0; }
#opacity { width: 160px; }
#opacityValue { min-width: 42px; }
.figures { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 14px; }
figure { margin: 0; min-width: 0; }
figcaption { font-weight: 600; margin-bottom: 7px; }
.viewport { border: 1px solid #c4cbd2; background: #e6e9ed; overflow: auto; max-height: 66vh; min-height: 140px; }
.viewport img { display: block; }
.viewport.fit > img { width: 100%; height: auto; }
.viewport.original > img { width: auto; height: auto; max-width: none; }
.image-meta { margin-top: 6px; font-size: 12px; color: #566575; overflow-wrap: anywhere; }
#overlayCanvas { position: relative; }
#overlayCanvas img { position: absolute; top: 0; left: 0; max-width: none; }
#differenceFigure { max-width: 1100px; margin: auto; }
#imageWarning, #formError { color: #9a251b; }
#imageWarning:empty, #formError:empty { display: none; }
#metrics, #referenceNotes { white-space: pre-wrap; overflow-wrap: anywhere; }
ul { margin: 8px 0; padding-left: 24px; line-height: 1.7; }
.table-scroll { overflow-x: auto; }
table { width: 100%; border-collapse: collapse; font-size: 13px; line-height: 1.6; }
th, td { padding: 9px 10px; border: 1px solid #ced4db; text-align: left; vertical-align: top; }
th { background: #f4f5f7; font-weight: 600; }
td.coordinates { white-space: nowrap; font-variant-numeric: tabular-nums; }
details { margin-top: 10px; }
details summary { cursor: pointer; }
.hashes { display: grid; grid-template-columns: 130px minmax(0, 1fr); gap: 8px; margin-top: 10px; }
.decision { display: grid; grid-template-columns: minmax(190px, 260px) minmax(0, 1fr); gap: 18px; }
.field { display: flex; flex-direction: column; gap: 7px; }
textarea { width: 100%; min-height: 94px; resize: vertical; border: 1px solid #a9b3bd; border-radius: 4px; padding: 9px; color: inherit; background: white; }
#saveMessage { min-height: 20px; }
[hidden] { display: none !important; }
@media (max-width: 680px) {
header, main { padding-left: 12px; padding-right: 12px; }
.panel { padding: 12px; }
.decision { grid-template-columns: 1fr; }
.figures { gap: 8px; }
.hashes { grid-template-columns: 1fr; }
.viewport { max-height: 55vh; }
}
</style>
</head>
<body>
<header>
<h1>DocView PDF 比較レビュー</h1>
<p class="notice">人による確認前の比較資料です。JSON を保存するだけで、設計全体の合格や配布承認にはなりません。</p>
<p class="muted">画像と確認項目を見比べ、ケースごとに判定してください。入力内容はこの画面のメモリ内だけに保持されます。再読込・終了で消えるため、必要な結果は JSON 保存してください。</p>
</header>
<main id="app" hidden>
<section class="panel" aria-label="ケース選択とレビュー保存">
<div class="row spread">
<div class="row case-control">
<label for="caseSelect">ケース</label>
<select id="caseSelect" aria-label="比較するケース"></select>
</div>
<div class="row">
<button id="previous" type="button" aria-label="前のケース">前へ</button>
<span id="caseCounter" aria-live="polite"></span>
<button id="next" type="button" aria-label="次のケース">次へ</button>
</div>
</div>
<p id="summary" class="muted" aria-live="polite"></p>
<div class="row spread" style="margin-top:14px">
<div class="control"><label for="reviewer">確認者</label><input id="reviewer" type="text" maxlength="128" autocomplete="off" placeholder="氏名または識別名"></div>
<button id="save" type="button">レビューを JSON 保存</button>
</div>
<p id="formError" role="alert"></p>
<p id="saveMessage" class="muted" role="status"></p>
<details id="exportFallback" hidden>
<summary>保存するJSON(ダウンロードできない場合はコピー)</summary>
<label class="field" for="exportJson" style="margin-top:10px">保存するJSON(ダウンロードできない場合はコピー)
<textarea id="exportJson" readonly rows="12" spellcheck="false" aria-describedby="exportJsonHelp"></textarea>
</label>
<p id="exportJsonHelp" class="muted">欄内で全選択してコピーし、テキストファイルに .json 拡張子で保存できます。表示内容は保存ボタンを押した時点の判定です。</p>
</details>
</section>
<section class="panel" aria-labelledby="caseTitle">
<div class="row spread"><h2 id="caseTitle"></h2><a id="sourceLink" target="_blank" rel="noopener">元の PDF を開く</a></div>
<p id="caseGroup" class="muted"></p>
<div class="row toolbar">
<div class="control"><label for="mode">表示</label><select id="mode"><option value="parallel">左右に並べる</option><option value="overlay">重ね合わせ</option><option id="differenceOption" value="difference">差分画像</option></select></div>
<div class="control"><label for="scale">画像の大きさ</label><select id="scale"><option value="fit">幅に合わせる</option><option value="original">原寸(画像 1 px = CSS 1 px)</option></select></div>
<div id="opacityControl" class="control" hidden><label for="opacity">右画像の不透明度</label><input id="opacity" type="range" min="0" max="100" value="50"><output id="opacityValue" for="opacity">50%</output></div>
</div>
<div id="parallel" class="figures">
<figure><figcaption id="leftLabel"></figcaption><div id="leftViewport" class="viewport fit" tabindex="0" aria-label="左の比較画像。原寸ではスクロールできます"><img id="leftImage" alt=""></div><div id="leftMeta" class="image-meta"></div></figure>
<figure><figcaption id="rightLabel"></figcaption><div id="rightViewport" class="viewport fit" tabindex="0" aria-label="右の比較画像。原寸ではスクロールできます"><img id="rightImage" alt=""></div><div id="rightMeta" class="image-meta"></div></figure>
</div>
<figure id="overlayFigure" hidden><figcaption id="overlayLabel"></figcaption><div id="overlayViewport" class="viewport" tabindex="0" aria-label="重ね合わせ画像"><div id="overlayCanvas"><img id="overlayLeft" alt="左画像"><img id="overlayRight" alt="右画像"></div></div><p class="muted">両画像の左上をそろえ、同じ倍率で重ねます。右画像の不透明度を変えて差を確認できます。</p></figure>
<figure id="differenceFigure" hidden><figcaption>差分画像</figcaption><div id="differenceViewport" class="viewport fit" tabindex="0" aria-label="差分画像"><img id="differenceImage" alt="左右画像の差分"></div><div id="differenceMeta" class="image-meta"></div></figure>
<p id="imageWarning" role="status"></p>
</section>
<section class="panel" aria-labelledby="checksTitle">
<h2 id="checksTitle">確認項目と比較条件</h2>
<ul id="checks"></ul>
<div id="regions" hidden>
<p id="regionCoordinateNote" class="muted">原寸画像の左上を (0,0) とした px 座標。枠線の太さを含まない注釈矩形。</p>
<div class="table-scroll"><table aria-label="確認対象の位置と表示期待" aria-describedby="regionCoordinateNote"><thead><tr><th scope="col">対象</th><th scope="col">位置 [x0, y0, x1, y1]</th><th scope="col">表示の期待</th><th scope="col">Poppler 比較可否</th></tr></thead><tbody id="regionRows"></tbody></table></div>
</div>
<p id="referenceNotes"></p>
<p id="metrics" class="muted"></p>
<details><summary>資料と画像の SHA-256</summary><div id="hashes" class="hashes"></div></details>
</section>
<section class="panel" aria-labelledby="decisionTitle">
<h2 id="decisionTitle" style="margin-bottom:12px">このケースの判定</h2>
<div class="decision">
<div class="field"><label for="status">確認状態</label><select id="status"><option value="unreviewed">未確認</option><option value="accepted">問題なし</option><option value="accepted_difference">差分許容</option><option value="needs_fix">要修正</option></select><span class="muted">初期値は未確認です。自動判定は行いません。</span></div>
<div class="field"><label for="notes">理由・確認メモ <span id="reasonRequired" class="muted">(差分許容では必須)</span></label><textarea id="notes" maxlength="4096" aria-describedby="reasonRequired" placeholder="差の場所、許容する理由、修正が必要な点など"></textarea></div>
</div>
</section>
<p class="muted">Alt + ← / → でもケースを切り替えられます。保存 JSON の全体承認状態は「未承認」のままです。資料のハッシュは生成時の値で、この画面でファイルの再ハッシュは行いません。</p>
</main>
<p id="loadError" class="notice" role="alert" hidden></p>
<noscript><p class="notice">このローカル比較画面には JavaScript が必要です。</p></noscript>
<script id="review-data" type="application/json">__REVIEW_DATA__</script>
<script>
(() => {
"use strict";
const element = id => document.getElementById(id);
const labels = {unreviewed: "未確認", accepted: "問題なし", accepted_difference: "差分許容", needs_fix: "要修正"};
const hashValid = value => typeof value === "string" && /^[a-f0-9]{64}$/.test(value);
function localUrl(value) {
if (typeof value !== "string" || !value || /^[\\/]/.test(value) || /[\\\u0000-\u001f]/.test(value) || /^[a-z][a-z0-9+.-]*:/i.test(value))
throw new Error("画像と原本のリンクにはローカル相対 URL が必要です。");
return value;
}
function asset(value) {
if (!value || !hashValid(value.sha256)) throw new Error("資料の SHA-256 が不正です。");
localUrl(value.url);
}
let data;
try {
data = JSON.parse(element("review-data").textContent);
if (data.schemaVersion !== 1 || !hashValid(data.bundleSha256) || !hashValid(data.workerSha256) || !Array.isArray(data.cases) || !data.cases.length || data.cases.length > 500)
throw new Error("比較資料の形式または件数が不正です。");
const ids = new Set();
for (const item of data.cases) {
if (typeof item.id !== "string" || !item.id || ids.has(item.id) || !Array.isArray(item.checks) || item.checks.some(value => typeof value !== "string"))
throw new Error("ケースの識別子または確認項目が不正です。");
ids.add(item.id);
asset(item.source); asset(item.left); asset(item.right);
if (item.difference !== null) asset(item.difference);
if (item.independentReference !== undefined && typeof item.independentReference !== "boolean")
throw new Error("独立参照の指定が不正です。");
if (item.regions !== undefined && (!Array.isArray(item.regions) || item.regions.length > 256 || item.regions.some(region =>
!region || typeof region.name !== "string" || typeof region.expectation !== "string" || typeof region.referenceSupported !== "boolean" ||
!Array.isArray(region.rect) || region.rect.length !== 4 || region.rect.some(value => typeof value !== "number" || !Number.isFinite(value)) ||
region.rect[2] < region.rect[0] || region.rect[3] < region.rect[1])))
throw new Error("確認対象の矩形または表示期待が不正です。");
}
} catch (error) {
element("loadError").textContent = "比較資料を読み込めませんでした。" + error.message;
element("loadError").hidden = false;
return;
}
const reviews = data.cases.map(() => ({status: "unreviewed", notes: ""}));
let current = 0;
const select = element("caseSelect");
data.cases.forEach((item, index) => {
const option = document.createElement("option");
option.value = String(index);
select.append(option);
});
function text(id, value) { element(id).textContent = value == null ? "" : String(value); }
function updateSummary() {
const counts = {unreviewed: 0, accepted: 0, accepted_difference: 0, needs_fix: 0};
reviews.forEach((review, index) => {
++counts[review.status];
const item = data.cases[index];
select.options[index].textContent = `${index + 1}. [${labels[review.status]}] ${item.group} / ${item.title}`;
});
text("summary", Object.keys(counts).map(key => `${labels[key]} ${counts[key]} 件`).join(" / "));
}
function imageMetadata() {
for (const side of ["left", "right", "difference"]) {
const image = element(side + "Image");
text(side + "Meta", image.naturalWidth ? `${image.naturalWidth} × ${image.naturalHeight} px` : "");
}
const left = element("leftImage"), right = element("rightImage");
if (left.naturalWidth && right.naturalWidth && (left.naturalWidth !== right.naturalWidth || left.naturalHeight !== right.naturalHeight))
text("imageWarning", "左右の画像寸法が異なります。重ね合わせでは同じ倍率を保ち、画像を引き伸ばして一致させません。");
layoutOverlay();
}
function layoutOverlay() {
const left = element("overlayLeft"), right = element("overlayRight");
const width = Math.max(left.naturalWidth, right.naturalWidth), height = Math.max(left.naturalHeight, right.naturalHeight);
if (!width || !height || element("overlayFigure").hidden) return;
const scale = element("scale").value === "fit" ? element("overlayViewport").clientWidth / width : 1;
const canvas = element("overlayCanvas");
canvas.style.width = `${width * scale}px`; canvas.style.height = `${height * scale}px`;
for (const image of [left, right]) {
image.style.width = `${image.naturalWidth * scale}px`;
image.style.height = `${image.naturalHeight * scale}px`;
}
}
function updateView() {
const mode = element("mode").value, scale = element("scale").value;
element("parallel").hidden = mode !== "parallel";
element("overlayFigure").hidden = mode !== "overlay";
element("differenceFigure").hidden = mode !== "difference";
element("opacityControl").hidden = mode !== "overlay";
for (const side of ["left", "right", "difference"]) element(side + "Viewport").className = "viewport " + scale;
element("overlayRight").style.opacity = String(Number(element("opacity").value) / 100);
text("opacityValue", element("opacity").value + "%");
layoutOverlay();
}
function show(index) {
current = Math.max(0, Math.min(data.cases.length - 1, index));
const item = data.cases[current], review = reviews[current];
select.value = String(current);
element("previous").disabled = current === 0;
element("next").disabled = current === data.cases.length - 1;
text("caseCounter", `${current + 1} / ${data.cases.length}`);
text("caseTitle", item.title); text("caseGroup", `${item.group} · ${item.id}`);
text("leftLabel", "左 · " + item.left.label); text("rightLabel", "右 · " + item.right.label);
text("overlayLabel", `${item.left.label} + ${item.right.label}`);
element("sourceLink").href = localUrl(item.source.url);
element("status").value = review.status; element("notes").value = review.notes;
element("notes").required = review.status === "accepted_difference";
text("referenceNotes", item.notes); text("metrics", item.metrics); text("imageWarning", "");
const checks = element("checks"); checks.replaceChildren();
for (const value of item.checks) { const li = document.createElement("li"); li.textContent = value; checks.append(li); }
const regionRows = element("regionRows"); regionRows.replaceChildren();
element("regions").hidden = !item.regions || item.regions.length === 0;
for (const region of item.regions || []) {
const row = document.createElement("tr");
const values = [region.name, "[" + region.rect.join(", ") + "]", region.expectation, region.referenceSupported ? "比較可" : "比較対象外"];
values.forEach((value, index) => { const cell = document.createElement("td"); cell.textContent = value; if (index === 1) cell.className = "coordinates"; row.append(cell); });
regionRows.append(row);
}
const hashes = element("hashes"); hashes.replaceChildren();
const rows = [["Bundle", data.bundleSha256], ["Worker", data.workerSha256], ["原本", item.source.sha256], ["左画像", item.left.sha256], ["右画像", item.right.sha256]];
if (item.difference) rows.push(["差分画像", item.difference.sha256]);
for (const [name, hash] of rows) { const label = document.createElement("span"), code = document.createElement("code"); label.textContent = name; code.textContent = hash; hashes.append(label, code); }
for (const [id, value] of [["leftImage", item.left], ["rightImage", item.right], ["overlayLeft", item.left], ["overlayRight", item.right]]) {
const image = element(id); image.alt = value.label; image.src = localUrl(value.url);
}
element("differenceOption").disabled = !item.difference;
if (item.difference) element("differenceImage").src = localUrl(item.difference.url);
else { element("differenceImage").removeAttribute("src"); if (element("mode").value === "difference") element("mode").value = "parallel"; }
for (const side of ["left", "right", "difference"]) text(side + "Meta", "");
for (const id of ["leftViewport", "rightViewport", "overlayViewport", "differenceViewport"]) { element(id).scrollTop = 0; element(id).scrollLeft = 0; }
updateView();
}
for (const id of ["leftImage", "rightImage", "overlayLeft", "overlayRight", "differenceImage"]) {
element(id).addEventListener("load", imageMetadata);
element(id).addEventListener("error", () => { if (element(id).hasAttribute("src")) text("imageWarning", "画像を開けません。資料のフォルダー構成と画像ファイルを確認してください。"); });
}
select.addEventListener("change", () => show(Number(select.value)));
element("previous").addEventListener("click", () => show(current - 1));
element("next").addEventListener("click", () => show(current + 1));
for (const id of ["mode", "scale"]) element(id).addEventListener("change", updateView);
element("opacity").addEventListener("input", updateView);
element("status").addEventListener("change", () => {
reviews[current].status = element("status").value;
element("notes").required = reviews[current].status === "accepted_difference";
text("formError", ""); text("saveMessage", "未保存の変更があります。"); updateSummary();
});
element("notes").addEventListener("input", () => { reviews[current].notes = element("notes").value; text("formError", ""); text("saveMessage", "未保存の変更があります。"); });
element("reviewer").addEventListener("input", () => { text("formError", ""); text("saveMessage", "未保存の変更があります。"); });
element("save").addEventListener("click", () => {
text("formError", "");
const reviewer = element("reviewer").value.trim();
if (!reviewer) { text("formError", "保存する前に確認者名を入力してください。"); element("reviewer").focus(); return; }
const missing = reviews.findIndex(value => value.status === "accepted_difference" && !value.notes.trim());
if (missing >= 0) { show(missing); text("formError", "差分を許容する理由を入力してください。"); element("notes").focus(); return; }
const copyAsset = value => value ? {url: value.url, sha256: value.sha256, ...(value.label ? {label: value.label} : {})} : null;
const record = {schemaVersion: 1, bundleSha256: data.bundleSha256, workerSha256: data.workerSha256,
reviewer, savedAt: new Date().toISOString(), overallApproval: "not-approved",
notice: "各ケースの人による判定を保存した記録。設計全体の合格・配布承認を意味しない。",
cases: data.cases.map((item, index) => ({id: item.id, group: item.group, title: item.title,
independentReference: item.independentReference === undefined ? null : item.independentReference,
status: reviews[index].status, notes: reviews[index].notes.trim(), source: copyAsset(item.source),
left: copyAsset(item.left), right: copyAsset(item.right), difference: copyAsset(item.difference)}))};
const json = JSON.stringify(record, null, 2) + "\n";
element("exportJson").value = json;
element("exportFallback").hidden = false;
element("exportFallback").open = true;
const url = URL.createObjectURL(new Blob([json], {type: "application/json;charset=utf-8"}));
const link = document.createElement("a"); link.href = url; link.download = `docview-pdf-review-${data.bundleSha256.slice(0, 12)}.json`;
document.body.append(link); link.click(); link.remove();
setTimeout(() => URL.revokeObjectURL(url), 30000);
text("saveMessage", "JSON の保存を開始しました。保存先はブラウザーで確認してください。全体承認状態は未承認です。");
});
document.addEventListener("keydown", event => {
if (event.altKey && !event.ctrlKey && !event.metaKey && (event.key === "ArrowLeft" || event.key === "ArrowRight")) {
event.preventDefault(); show(current + (event.key === "ArrowRight" ? 1 : -1));
}
});
new ResizeObserver(layoutOverlay).observe(element("overlayViewport"));
element("app").hidden = false; updateSummary(); show(0);
})();
</script>
</body>
</html>