241 lines
15 KiB
Python
241 lines
15 KiB
Python
#!/usr/bin/env python3
|
||
"""Assemble existing rendering evidence for human review; never grant approval."""
|
||
import argparse
|
||
import hashlib
|
||
import json
|
||
import os
|
||
from pathlib import Path
|
||
import struct
|
||
from urllib.parse import quote
|
||
|
||
ROOT = Path(__file__).resolve().parents[1]
|
||
TESTS = ROOT / 'tests'
|
||
RESULTS = TESTS / 'results'
|
||
OUTPUT = RESULTS / 'render-review'
|
||
|
||
|
||
def sha(path):
|
||
with path.open('rb') as handle:
|
||
return hashlib.file_digest(handle, 'sha256').hexdigest()
|
||
|
||
|
||
def asset(path, expected=None, label=None):
|
||
path = path.resolve(strict=True)
|
||
if not path.is_relative_to(TESTS):
|
||
raise ValueError(f'Evidence outside tests directory: {path}')
|
||
digest = sha(path)
|
||
if expected is not None and expected != digest:
|
||
raise ValueError(f'Stale evidence: {path}')
|
||
value = {'url': quote(os.path.relpath(path, OUTPUT)), 'sha256': digest}
|
||
if label:
|
||
value['label'] = label
|
||
if path.suffix == '.png':
|
||
with path.open('rb') as handle:
|
||
header = handle.read(24)
|
||
if header[:8] != b'\x89PNG\r\n\x1a\n' or header[12:16] != b'IHDR':
|
||
raise ValueError(f'Invalid PNG header: {path}')
|
||
value['size'] = list(struct.unpack('>II', header[16:24]))
|
||
return value
|
||
|
||
|
||
def main():
|
||
global RESULTS, OUTPUT
|
||
parser = argparse.ArgumentParser(description=__doc__)
|
||
parser.add_argument('--results-root', type=Path, default=RESULTS,
|
||
help='Parent of the six comparison directories; gallery is written to render-review below it.')
|
||
parser.add_argument('--build-dir', type=Path, default=ROOT / 'build',
|
||
help='Build whose PDF worker must match all six comparison reports.')
|
||
parser.add_argument('--output', type=Path,
|
||
help='Gallery directory; defaults to RESULTS_ROOT/render-review.')
|
||
args = parser.parse_args()
|
||
RESULTS = args.results_root.resolve()
|
||
OUTPUT = args.output.resolve() if args.output else RESULTS / 'render-review'
|
||
worker = sha(args.build_dir.resolve() / 'docview-pdf-worker')
|
||
comparisons = {}
|
||
inputs = []
|
||
for name in ['pdf', 'pdf-extended', 'pdf-fonts', 'link-borders', 'pdf-icc', 'font-collection']:
|
||
path = RESULTS / name / 'comparison.json'
|
||
values = json.loads(path.read_text())
|
||
if values['worker_sha256'] != worker:
|
||
raise ValueError(f'Comparison does not match current worker: {name}')
|
||
comparisons[name] = values
|
||
inputs.append(asset(path))
|
||
cases = []
|
||
|
||
def add(id_, group, title, source, source_hash, left, right, diff, size, checks,
|
||
notes, measurement, independent=True):
|
||
entry = {
|
||
'id': id_, 'group': group, 'title': title,
|
||
'source': asset(source, source_hash),
|
||
'left': asset(left, label='DocView / PDFium' if independent else 'PDFium:個別TTF'),
|
||
'right': asset(right, label='Poppler(独立レンダラー)' if independent else 'PDFium:TTC'),
|
||
'difference': asset(diff) if diff else None,
|
||
'independentReference': independent,
|
||
'checks': checks, 'notes': notes,
|
||
'metrics': f'{size[0]} × {size[1]} px / RGB平均絶対差 {measurement:.4f} / 255(合否閾値ではありません)',
|
||
}
|
||
for key in ['left', 'right', 'difference']:
|
||
if entry[key] and entry[key]['size'] != size:
|
||
raise ValueError(f'Dimension mismatch: {id_}/{key}')
|
||
cases.append(entry)
|
||
|
||
basic = comparisons['pdf']
|
||
add('basic-page-1', '基本描画', '本文・リンク・保存済みフォーム外観',
|
||
TESTS / 'fixtures/pdf/navigation.pdf', basic['fixture_sha256'],
|
||
RESULTS / 'pdf/pdfium.png', RESULTS / 'pdf/poppler.png', RESULTS / 'pdf/difference.png',
|
||
basic['size'], ['Heading One、Needle、Second MCIDと黒い矩形が見える。',
|
||
'保存されたフォーム外観は赤い矩形。下部のリンク枠が見える。'],
|
||
'文字のアンチエイリアスと、保存外観のないText注釈のアイコンは描画器により異なります。'
|
||
'フィールド値 Saved の文字表示はこの資料の期待値ではありません。第2ページはこの比較に含みません。',
|
||
basic['mean_absolute_channel_error'])
|
||
|
||
extended_checks = {
|
||
'japanese-layout': [],
|
||
'graphics-compositing': [
|
||
'RGB/RGBA画像、円形クリップ、グラデーション、透明な重なり、CMYK色見本が表示される。',
|
||
'50%透明合成の3点は生成時の期待色に対して両描画器とも各チャンネル差2以内。'],
|
||
'page-geometry': [
|
||
'赤・緑・青・橙の4隅のマーカーがページの回転に応じた位置にある。',
|
||
'CropBoxの外にあるピンク色の余白が表示されない。'],
|
||
'unembedded-font': ['Standard-14のテキストが欠落せず、行と字形を確認できる。'],
|
||
'icc-linear-rgb': [
|
||
'上段ICCベクターと下段ICC画像は、中段DeviceRGBより明るい。',
|
||
'線形0.5の灰色は約188、中段DeviceRGBは128。'],
|
||
}
|
||
extended_notes = {
|
||
'japanese-layout': '埋込BIZ UDPGothicと明示的な文字座標の検証です。この縦列はnative縦書きシェーピングの試験ではありません。',
|
||
'graphics-compositing': '文字や画像の輪郭にはラスタライズの差があります。全ての合成・印刷色を検証する資料ではありません。',
|
||
'page-geometry': '4ページ合計16個のマーカーを生成元の座標と照合済みです。',
|
||
'unembedded-font': 'DocViewはLiberation、PopplerはNimbusへ代替します。書体の形状差と文字欠落を区別してください。',
|
||
'icc-linear-rgb': '自作D65・sRGB原色・gamma 1のmatrix/TRCプロファイル。ICC LUT、校正済みCMYK、実モニターの発色は対象外です。',
|
||
}
|
||
extended_titles = {'japanese-layout': '埋込日本語・文字配置', 'graphics-compositing': '画像・透明・クリップ',
|
||
'page-geometry': 'ページ寸法・回転・切り抜き', 'unembedded-font': '非埋込Standard-14',
|
||
'icc-linear-rgb': 'ICC入力色(144 DPI)'}
|
||
for doc in comparisons['pdf-extended']['documents']:
|
||
stem = Path(doc['file']).stem
|
||
base = RESULTS / 'pdf-extended' / stem
|
||
for page in doc['pages']:
|
||
n = page['page']
|
||
checks = extended_checks[stem]
|
||
if stem == 'japanese-layout':
|
||
checks = (['「日本語の文字配置と埋込みフォント」が読める。',
|
||
'横組の本文と明示配置されたルビが読め、文字が重ならない。'] if n == 1 else
|
||
['縦列「日本語縦書き検証」「天地左右回転配置」の順序と配置を確認する。',
|
||
'0°・90°・180°・270°に回転した文字がそれぞれ表示される。'])
|
||
add(f'extended-{stem}-{n}', '追加PDF', f'{extended_titles[stem]}:{n}ページ',
|
||
TESTS / 'fixtures/pdf/extended' / doc['file'], doc['fixture_sha256'],
|
||
base / f'pdfium/page-{n}.png', base / f'poppler/page-{n}.png', base / f'difference-{n}.png',
|
||
page['expected_size_px'], checks, extended_notes[stem],
|
||
page['mean_absolute_channel_error'])
|
||
|
||
fonts = comparisons['pdf-fonts']
|
||
for page in fonts['pages']:
|
||
n = page['page']
|
||
add(f'japanese-unembedded-{n}', '日本語フォント', '非埋込CID:' + ('横書き' if n == 1 else '縦書き'),
|
||
TESTS / 'fixtures/pdf/fonts/unembedded-japanese.pdf', fonts['fixture_sha256'],
|
||
RESULTS / f'pdf-fonts/worker/page-{n}.png', RESULTS / f'pdf-fonts/poppler-{n}.png',
|
||
RESULTS / f'pdf-fonts/difference-{n}.png', page['size'],
|
||
['「日本語の文書を表示します。」「漢字とひらがな、カタカナ。」が読める。',
|
||
'「「縦書き」の句読点を確認。」の文字と句読点の位置、横組/縦組の順序を確認する。'],
|
||
'UniJIS-UCS2-H/V。DocViewはIPAexMincho / BIZ UDPGothicを代替に選択し、Popplerとは書体が異なります。'
|
||
'元のHeisei書体そのものの再現、全CMapやWindowsの互換性を示すものではありません。',
|
||
page['mean_absolute_channel_error'])
|
||
|
||
borders = comparisons['link-borders']
|
||
border_manifest = TESTS / 'fixtures/pdf/link-borders/manifest.json'
|
||
inputs.append(asset(border_manifest))
|
||
border_source = next(item for item in json.loads(border_manifest.read_text())['fixtures']
|
||
if item['file'] == 'styles.pdf')
|
||
if border_source['sha256'] != borders['fixture_sha256']:
|
||
raise ValueError('Link border manifest identity mismatch')
|
||
for page in borders['pages']:
|
||
n = page['page']
|
||
add(f'link-borders-{n}', 'リンク注釈', f'リンク枠:回転{page["rotation"]}°',
|
||
TESTS / 'fixtures/pdf/link-borders/styles.pdf', borders['fixture_sha256'],
|
||
RESULTS / f'link-borders/worker/page-{n}.png', RESULTS / f'link-borders/poppler-{n}.png',
|
||
RESULTS / f'link-borders/difference-{n}.png', page['whole_page']['size'],
|
||
['実線・破線の間隔・下線・BS優先・丸角・色・半透明・bevel/insetを下の位置表に沿って確認する。',
|
||
'zero-width、transparent、hidden、no-view、empty-appearance-preservedの枠は見えない。',
|
||
'appearance-preservedは緑の保存外観だけが見え、余分な赤枠が追加されない。'],
|
||
'Popplerの保存外観なしリンク補完はgray/CMYK、丸角、bevel/inset、alphaに制約があり、'
|
||
'保存AP/空APにも余分な枠を描きます。これらは右画像を正解にせず左の期待値を確認してください。'
|
||
'NoZoom/NoRotateの試験は別記録です。', page['whole_page']['mean_absolute_channel_error'])
|
||
regions = []
|
||
references = {item['name']: item['reference_supported'] for item in page['annotations']}
|
||
for annotation in border_source['annotations']:
|
||
x1, y1, x2, y2 = annotation['rect']
|
||
points = []
|
||
# Fixture CropBox [20,30,530,450], 144 DPI. Convert PDF bottom-left
|
||
# coordinates to the top-left of the actual rotated raster.
|
||
for x in (x1, x2):
|
||
for y in (y1, y2):
|
||
px, py = (x - 20) * 2, (450 - y) * 2
|
||
points.append([(px, py), (840 - py, px), (1020 - px, 840 - py),
|
||
(py, 1020 - px)][page['rotation'] // 90])
|
||
rect = [min(p[0] for p in points), min(p[1] for p in points),
|
||
max(p[0] for p in points), max(p[1] for p in points)]
|
||
name = annotation['name']
|
||
expectation = '表示される' if annotation['has_border'] else '表示されない'
|
||
if name == 'appearance-preserved':
|
||
expectation = '緑の保存外観だけが表示される(赤い追加枠なし)'
|
||
if name == 'empty-appearance-preserved':
|
||
expectation = '空の保存外観を保つ(追加枠なし)'
|
||
regions.append({'name': name, 'rect': rect, 'expectation': expectation,
|
||
'referenceSupported': references[name]})
|
||
cases[-1]['regions'] = regions
|
||
|
||
icc = comparisons['pdf-icc']
|
||
if icc['fixture_sha256_before'] != icc['fixture_sha256_after'] or not icc['all_source_assertions_pass']:
|
||
raise ValueError('ICC source assertions are not current/passing')
|
||
for scale in icc['scales']:
|
||
value = scale['scale']
|
||
base = RESULTS / 'pdf-icc' / ('scale-' + str(value).replace('.', '_'))
|
||
add(f'icc-scale-{value}', 'ICCと倍率', f'ICC:{value:g}倍({scale["dpi"]:g} DPI)',
|
||
TESTS / 'fixtures/pdf/extended/icc-linear-rgb.pdf', icc['fixture_sha256_before'],
|
||
base / 'pdfium/page-1.png', base / 'poppler.png', base / 'difference.png', scale['expected_size'],
|
||
extended_checks['icc-linear-rgb'] + ['この倍率の18色probeは理論値から各チャンネル差3以内。'],
|
||
extended_notes['icc-linear-rgb'], scale['mean_absolute_channel_error'])
|
||
|
||
collection = comparisons['font-collection']
|
||
for pair in collection['comparisons']:
|
||
name = pair['case']
|
||
runs = [r for r in collection['runs'] if r['case'] == name]
|
||
if len(runs) != 2 or runs[0]['source_sha256'] != runs[1]['source_sha256']:
|
||
raise ValueError('TTC source identity mismatch')
|
||
source = (RESULTS / 'font-collection/regular-bold.pdf' if name == 'paired-styles'
|
||
else TESTS / 'fixtures/pdf/extended/unembedded-font.pdf')
|
||
left = RESULTS / f'font-collection/standalone/{name}/page-1.png'
|
||
right = RESULTS / f'font-collection/collection/{name}/page-1.png'
|
||
asset(left, pair['standalone_png_sha256'])
|
||
asset(right, pair['collection_png_sha256'])
|
||
add(f'ttc-{name}', 'TTC/TTF(同一描画器)', 'TTCとTTF:' + ('通常/太字' if name == 'paired-styles' else 'Standard-14'),
|
||
source, runs[0]['source_sha256'], left, right, None, pair['dimensions'],
|
||
['左右が同じ画像で、TTCのface選択が個別TTFと一致する。'] +
|
||
(['同じ文「Collection face 0123456789 AVWA」の通常と太字が区別できる。'] if name == 'paired-styles' else []),
|
||
'同じPDFiumでフォントの格納形式を変えた比較です。独立レンダラーによる正解画像ではありません。'
|
||
'Latinの静的TrueTypeを対象とし、CJK/CFF/Windowsはこの比較に含みません。試験用フォント本体は回収済みです。',
|
||
pair['mean_absolute_channel_error'], independent=False)
|
||
|
||
if len(cases) != 21 or len({c['id'] for c in cases}) != 21:
|
||
raise ValueError('Expected exactly 21 distinct review cases')
|
||
template_path = TESTS / 'render_review/index.template.html'
|
||
template = template_path.read_text()
|
||
if template.count('__REVIEW_DATA__') != 1:
|
||
raise ValueError('Review template must contain one data placeholder')
|
||
data = {'schemaVersion': 1, 'workerSha256': worker, 'approvalStatus': 'pending-human-review',
|
||
'templateSha256': sha(template_path), 'generatorSha256': sha(Path(__file__)),
|
||
'comparisonInputs': inputs, 'cases': cases}
|
||
canonical = json.dumps(data, ensure_ascii=False, sort_keys=True, separators=(',', ':')).encode()
|
||
data['bundleSha256'] = hashlib.sha256(canonical).hexdigest()
|
||
serialized = json.dumps(data, ensure_ascii=False, indent=2) + '\n'
|
||
escaped = serialized.replace('&', '\\u0026').replace('<', '\\u003c').replace('>', '\\u003e')
|
||
OUTPUT.mkdir(parents=True, exist_ok=True)
|
||
(OUTPUT / 'manifest.json').write_text(serialized)
|
||
(OUTPUT / 'index.html').write_text(template.replace('__REVIEW_DATA__', escaped))
|
||
print(f'Generated {len(cases)} pairs: {data["bundleSha256"]}; approval remains pending.')
|
||
|
||
|
||
if __name__ == '__main__':
|
||
main()
|