Files
docview/tests/fixtures/pdf/extended_generator.py
2026-09-21 13:41:40 +09:00

190 lines
11 KiB
Python

#!/usr/bin/env python3
"""Create deterministic, original PDF fixtures with explicit drawing geometry.
Dependencies: ReportLab, pypdf, Pillow. The Japanese font is subset-embedded;
its adjacent SIL OFL license is copied alongside the generated documents.
"""
from pathlib import Path
from io import BytesIO
import hashlib
import json
import os
import shutil
from PIL import Image, ImageDraw
from pypdf import PdfReader, PdfWriter
from pypdf.generic import NameObject, NumberObject, RectangleObject
from reportlab.lib import colors
from reportlab.lib.utils import ImageReader
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont
from reportlab.pdfgen import canvas
HERE = Path(__file__).resolve().parent
OUT = HERE / "extended"
FONT = Path(os.environ.get("DOCVIEW_JAPANESE_FONT", "/usr/local/share/fonts/BIZ_UDPGothic/BIZUDPGothic-Regular.ttf"))
LICENSE = FONT.parent / "OFL.txt"
OUT.mkdir(parents=True, exist_ok=True)
if not FONT.is_file() or not LICENSE.is_file():
raise SystemExit("Set DOCVIEW_JAPANESE_FONT to BIZUDPGothic-Regular.ttf with its adjacent OFL.txt.")
license_text = LICENSE.read_text(encoding="utf-8")
if "SIL OPEN FONT LICENSE Version 1.1" not in license_text:
raise SystemExit("The expected SIL Open Font License was not found.")
pdfmetrics.registerFont(TTFont("FixtureJapanese", str(FONT)))
shutil.copyfile(LICENSE, OUT / "FONT-OFL.txt")
manifest = {
"origin": "Original deterministic vector, text and raster drawings authored for DocView tests",
"reference_status": "Synthetic source specifies content and coordinates; no human-approved golden image exists",
"font": {
"family": "BIZ UDPGothic Regular", "source_file": FONT.name,
"sha256": hashlib.sha256(FONT.read_bytes()).hexdigest(),
"license": "SIL Open Font License 1.1", "license_copy": "FONT-OFL.txt",
"upstream": "https://github.com/googlefonts/morisawa-biz-ud-gothic",
"upstream_license": "https://raw.githubusercontent.com/googlefonts/morisawa-biz-ud-gothic/main/OFL.txt",
"embedding": "Subset embedded by ReportLab; OFL permits embedding and redistribution. No reserved font name is changed.",
},
"documents": [],
}
def new_canvas(name, size):
result = canvas.Canvas(str(OUT / name), pagesize=size, invariant=1, pageCompression=1)
result.setTitle("DocView synthetic rendering fixture - " + name)
result.setAuthor("DocView tests")
return result
def label(c, x, y, text, size=11):
c.setFillColor(colors.HexColor("#263746"))
c.setFont("Helvetica", size)
c.drawString(x, y, text)
def japanese(c, x, y, text, size=21):
c.setFillColor(colors.HexColor("#132c35"))
c.setFont("FixtureJapanese", size)
c.drawString(x, y, text)
def record(name, pages, expected, limits):
manifest["documents"].append({"file": name, "sha256": hashlib.sha256((OUT / name).read_bytes()).hexdigest(),
"page_count": pages, "expected": expected, "limits": limits})
c = new_canvas("japanese-layout.pdf", (540, 720))
label(c, 38, 675, "EMBEDDED JAPANESE / HORIZONTAL", 17)
japanese(c, 38, 614, "日本語の文字配置と埋込みフォント", 23)
japanese(c, 38, 557, "漢字・ひらがな・カタカナ・1234", 21)
japanese(c, 38, 500, "読み取り専用の文書表示を検証します。", 20)
label(c, 38, 454, "ASCII text uses the same embedded Japanese font:")
japanese(c, 38, 420, "DocView 0123456789 ABC xyz", 20)
japanese(c, 55, 330, "漢字", 29)
japanese(c, 55, 364, "かんじ", 11)
label(c, 155, 341, "Ruby has explicit, independent PDF coordinates.")
c.setStrokeColor(colors.HexColor("#147d79")); c.setLineWidth(1)
c.rect(38, 220, 464, 76, fill=0)
japanese(c, 53, 262, "天地左右", 23)
label(c, 53, 236, "Four ideographs above must remain distinct and correctly placed.")
label(c, 38, 55, "540 x 720 pt / exact source coordinates / embedded TrueType subset")
c.showPage()
label(c, 38, 675, "EMBEDDED JAPANESE / EXPLICIT VERTICAL POSITIONS", 15)
columns = [{"x": 420, "top": 590, "step": 32, "text": "日本語縦書き検証"},
{"x": 374, "top": 590, "step": 32, "text": "天地左右回転配置"}]
for col in columns:
for index, glyph in enumerate(col["text"]):
japanese(c, col["x"], col["top"] - index * col["step"], glyph, 24)
c.setStrokeColor(colors.HexColor("#d7e8e6")); c.line(col["x"] - 5, 315, col["x"] - 5, 620)
for x, y, angle in [(80, 525, 0), (230, 485, 90), (200, 310, 180), (65, 350, 270)]:
c.saveState(); c.translate(x, y); c.rotate(angle); japanese(c, 0, 0, "天地左右", 20); c.restoreState()
label(c, 38, 228, "Right column is read top to bottom; the next column is to its left.")
label(c, 38, 205, "Four lower-left samples use text matrices at 0 / 90 / 180 / 270 degrees.")
label(c, 38, 73, "This tests explicit glyph placement, not vertical CID metrics or shaping.")
label(c, 38, 55, "540 x 720 pt / each glyph has a documented baseline")
c.save()
record("japanese-layout.pdf", 2,
{"page_sizes_pt": [[540, 720], [540, 720]], "horizontal_text": "日本語の文字配置と埋込みフォント",
"vertical_columns": columns, "text_matrix_rotations_degrees": [0, 90, 180, 270], "font_embedded": True},
["Manual vertical glyph placement does not test native vertical CID metrics or OpenType shaping.", "Ruby placement is explicit source geometry, not automatic ruby layout."])
c = new_canvas("graphics-compositing.pdf", (600, 760))
label(c, 35, 721, "IMAGES / ALPHA / CLIPPING / SHADING / CMYK", 17)
image = Image.new("RGB", (96, 96), "white"); drawing = ImageDraw.Draw(image)
for y in range(0, 96, 12):
for x in range(0, 96, 12): drawing.rectangle((x, y, x + 11, y + 11), fill="#147d79" if (x + y) // 12 % 2 == 0 else "#e9b44c")
drawing.line((0, 0, 95, 95), fill="black", width=4)
c.drawImage(ImageReader(image), 40, 524, width=144, height=144)
label(c, 40, 505, "96 x 96 RGB image, enlarged 1.5x")
alpha = Image.new("RGBA", (96, 96), (0, 0, 0, 0)); d = ImageDraw.Draw(alpha)
d.ellipse((4, 4, 92, 92), fill=(224, 63, 90, 150)); d.rectangle((15, 37, 81, 58), fill=(30, 70, 220, 190))
c.setFillColor(colors.HexColor("#dfe7ed")); c.rect(310, 524, 144, 144, stroke=0, fill=1)
c.drawImage(ImageReader(alpha), 310, 524, width=144, height=144, mask="auto")
label(c, 310, 505, "RGBA image with soft mask")
c.setFillColor(colors.HexColor("#e8edf1")); c.rect(40, 330, 220, 135, fill=1, stroke=0)
c.saveState(); c.setFillColor(colors.red); c.setFillAlpha(0.5); c.rect(55, 345, 95, 95, fill=1, stroke=0)
c.setFillColor(colors.blue); c.setFillAlpha(0.5); c.rect(112, 365, 105, 75, fill=1, stroke=0); c.restoreState()
label(c, 40, 308, "Two vector rectangles at 50% alpha")
c.saveState(); path = c.beginPath(); path.circle(390, 395, 64); c.clipPath(path, stroke=0, fill=0)
c.drawImage(ImageReader(image), 300, 310, width=180, height=180); c.restoreState()
c.setStrokeColor(colors.HexColor("#263746")); c.circle(390, 395, 64, fill=0, stroke=1)
label(c, 310, 308, "Raster image clipped to a circle")
c.saveState(); path = c.beginPath(); path.rect(40, 205, 485, 62); c.clipPath(path, stroke=0, fill=0)
c.linearGradient(40, 205, 525, 205, [colors.HexColor("#147d79"), colors.white, colors.HexColor("#d95050")], [0, 0.5, 1]); c.restoreState()
label(c, 40, 185, "Axial PDF shading, clipped to a 485 x 62 pt band")
swatches = [(1, 0, 0, 0), (0, 1, 0, 0), (0, 0, 1, 0), (0, 0, 0, 1)]
for index, value in enumerate(swatches):
c.setFillColorCMYK(*value); c.rect(40 + index * 124, 76, 105, 67, stroke=0, fill=1)
label(c, 40 + index * 124, 56, "CMYK " + "".join(map(str, value)), 10)
c.save()
record("graphics-compositing.pdf", 1, {"page_sizes_pt": [[600, 760]], "rgb_image_pixels": [96, 96],
"alpha": 0.5, "alpha_probes": [
{"point_pt": [80, 370], "expected_rgb": [244, 119, 121], "region": "red over gray"},
{"point_pt": [180, 390], "expected_rgb": [116, 119, 248], "region": "blue over gray"},
{"point_pt": [130, 390], "expected_rgb": [122, 59, 188], "region": "blue over red over gray"}],
"clip_circle_pt": [390, 395, 64], "axial_shading_band_pt": [40, 205, 485, 62], "cmyk_swatches": swatches},
["CMYK output depends on renderer color conversion; no approved ICC reference is supplied.", "Pixel differences are evidence, not a calibrated quality threshold."])
geometries = [([420, 320], [30, 40, 390, 300], 0), ([560, 360], [20, 30, 520, 330], 90),
([300, 600], [15, 50, 285, 550], 270), ([480, 480], [60, 80, 420, 400], 180)]
geometry_buffer = BytesIO(); c = canvas.Canvas(geometry_buffer, invariant=1, pageCompression=1)
expectations = []
for number, (size, crop, rotation) in enumerate(geometries, 1):
c.setPageSize(size)
left, bottom, right, top = crop; width, height = right - left, top - bottom
c.setFillColor(colors.HexColor("#ffd6d6")); c.rect(0, 0, *size, fill=1, stroke=0)
c.setFillColor(colors.white); c.rect(left, bottom, width, height, fill=1, stroke=0)
c.setStrokeColor(colors.black); c.setLineWidth(1); c.rect(left + 1, bottom + 1, width - 2, height - 2, fill=0)
dots = [(left + 20, bottom + 20, [255, 0, 0]), (right - 20, bottom + 20, [0, 170, 0]),
(right - 20, top - 20, [0, 0, 255]), (left + 20, top - 20, [255, 170, 0])]
for x, y, rgb in dots:
c.setFillColor(colors.Color(*(v / 255 for v in rgb))); c.circle(x, y, 8, fill=1, stroke=0)
label(c, left + 35, bottom + height * 0.55, f"PAGE {number} / Rotate {rotation}", 15)
label(c, left + 35, bottom + height * 0.55 - 25, f"CropBox {crop}", 10)
label(c, left + 35, bottom + height * 0.55 - 45, "Pink margin must be excluded.", 10)
expectations.append({"media_box_pt": [0, 0, *size], "crop_box_pt": crop, "rotation": rotation,
"display_size_pt": [height, width] if rotation % 180 else [width, height],
"markers_pdf_coordinates": [{"x": x, "y": y, "rgb": rgb} for x, y, rgb in dots]})
c.showPage()
c.save(); source = PdfReader(BytesIO(geometry_buffer.getvalue())); writer = PdfWriter()
for page, (_, crop, rotation) in zip(source.pages, geometries):
page.cropbox = RectangleObject(crop); page[NameObject("/Rotate")] = NumberObject(rotation); writer.add_page(page)
writer.add_metadata({"/Title": "DocView page geometry fixture", "/Author": "DocView tests"})
with (OUT / "page-geometry.pdf").open("wb") as output: writer.write(output)
record("page-geometry.pdf", 4, {"pages": expectations}, ["Renderer comparisons must use CropBox and honor document Rotate."])
c = new_canvas("unembedded-font.pdf", (400, 300))
label(c, 30, 252, "UNEMBEDDED STANDARD PDF FONT", 15)
label(c, 30, 205, "Helvetica: ABCDEFGHIJKLMNOPQRSTUVWXYZ", 12)
label(c, 30, 176, "abcdefghijklmnopqrstuvwxyz 0123456789", 12)
c.setFont("Times-Roman", 15); c.drawString(30, 137, "Times-Roman: replacement font baseline")
c.setFont("Courier", 14); c.drawString(30, 100, "Courier: 0123456789 fixed pitch")
label(c, 30, 46, "Standard-14 fonts are intentionally not embedded.", 11)
c.save()
record("unembedded-font.pdf", 1, {"page_sizes_pt": [[400, 300]], "base_fonts": ["Helvetica", "Times-Roman", "Courier"], "font_embedded": False},
["This is a controlled Standard-14 substitution baseline, not arbitrary missing Japanese font coverage.", "Cross-OS fallback is not established by this Linux-only comparison."])
from icc_generator import generate as generate_icc
icc_record, manifest["color_profile"] = generate_icc(OUT)
manifest["documents"].append(icc_record)
(OUT / "manifest.json").write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(f"Generated {len(manifest['documents'])} PDF fixtures under {OUT}")