Files
docview/tests/render_review/index.template.html
T
2026-09-21 13:41:40 +09:00

336 lines
24 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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>