mirror of
https://github.com/storytold/winprint.rs.git
synced 2026-11-08 03:23:20 +00:00
461 lines
14 KiB
Python
Executable File
461 lines
14 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import math
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
from pathlib import Path
|
|
|
|
from reportlab.lib import colors
|
|
from reportlab.lib.pagesizes import A4
|
|
from reportlab.pdfgen import canvas
|
|
from reportlab.lib.units import mm
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Layout
|
|
# ---------------------------------------------------------------------------
|
|
|
|
SCRIPT_DIR = Path(__file__).resolve().parent
|
|
REPO_ROOT = SCRIPT_DIR.parent.parent
|
|
VR_DIR = REPO_ROOT / "test_data" / "visual_regression"
|
|
|
|
PAGE_SIZE = A4
|
|
PAGE_W, PAGE_H = PAGE_SIZE
|
|
|
|
# Baseline rendering DPI.
|
|
BASELINE_DPI = 300
|
|
TIFF_DOWN_SCALE_FACTOR = 4
|
|
|
|
# Pin reportlab / Ghostscript output timestamps for byte-stable output.
|
|
os.environ.setdefault("SOURCE_DATE_EPOCH", "1700000000")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tool discovery
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
REQUIRED_TOOLS = ("gs", "tiffcp")
|
|
|
|
|
|
def _require_tools() -> None:
|
|
missing = [t for t in REQUIRED_TOOLS if shutil.which(t) is None]
|
|
if missing:
|
|
raise SystemExit("Missing required tools: {}\n".format(", ".join(missing)))
|
|
|
|
|
|
def _run(cmd: list[str]) -> None:
|
|
"""Run a subprocess, surfacing a readable error on failure."""
|
|
subprocess.run(cmd, capture_output=True, text=True, check=True)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# data.pdf — reportlab drawing
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _draw_page_header(c: canvas.Canvas, page_no: int, title: str) -> None:
|
|
c.setFillColor(colors.HexColor("#1f3a5f"))
|
|
c.rect(0, PAGE_H - 22 * mm, PAGE_W, 22 * mm, fill=1, stroke=0)
|
|
c.setFillColor(colors.white)
|
|
c.setFont("Helvetica-Bold", 18)
|
|
c.drawString(15 * mm, PAGE_H - 14 * mm, title)
|
|
c.setFont("Helvetica", 10)
|
|
c.drawRightString(PAGE_W - 15 * mm, PAGE_H - 14 * mm, f"Page {page_no}")
|
|
|
|
|
|
def _draw_footer(c: canvas.Canvas, page_no: int) -> None:
|
|
c.setFillColor(colors.HexColor("#777777"))
|
|
c.setFont("Helvetica-Oblique", 8)
|
|
c.drawCentredString(
|
|
PAGE_W / 2,
|
|
10 * mm,
|
|
f"winprint.rs visual regression fixture — page {page_no}",
|
|
)
|
|
|
|
|
|
def _page_color_swatches(c: canvas.Canvas) -> None:
|
|
"""Page 1: a grid of saturated primary / secondary colour swatches."""
|
|
_draw_page_header(c, 1, "Color Swatches")
|
|
|
|
swatches = [
|
|
("Red", "#E53935"),
|
|
("Green", "#43A047"),
|
|
("Blue", "#1E88E5"),
|
|
("Cyan", "#00ACC1"),
|
|
("Magenta", "#D81B60"),
|
|
("Yellow", "#FDD835"),
|
|
("Orange", "#FB8C00"),
|
|
("Purple", "#8E24AA"),
|
|
("Teal", "#00897B"),
|
|
("Pink", "#EC407A"),
|
|
("Lime", "#C0CA33"),
|
|
("Indigo", "#3949AB"),
|
|
]
|
|
|
|
margin = 20 * mm
|
|
grid_w = PAGE_W - 2 * margin
|
|
grid_h = PAGE_H - 60 * mm
|
|
cols, rows = 3, 4
|
|
cell_w = grid_w / cols
|
|
cell_h = grid_h / rows
|
|
|
|
for i, (name, hexv) in enumerate(swatches):
|
|
col = i % cols
|
|
row = i // cols
|
|
x = margin + col * cell_w
|
|
y = PAGE_H - 40 * mm - (row + 1) * cell_h
|
|
c.setFillColor(colors.HexColor(hexv))
|
|
c.rect(x + 3, y + 3, cell_w - 6, cell_h - 14, fill=1, stroke=0)
|
|
c.setFillColor(colors.black)
|
|
c.setFont("Helvetica-Bold", 11)
|
|
c.drawString(x + 6, y, f"{name} {hexv}")
|
|
|
|
_draw_footer(c, 1)
|
|
|
|
|
|
def _page_text_styles(c: canvas.Canvas) -> None:
|
|
"""Page 2: varied fonts, sizes, weights, colours, alignments."""
|
|
_draw_page_header(c, 2, "Text Styles")
|
|
|
|
y = PAGE_H - 40 * mm
|
|
|
|
c.setFillColor(colors.black)
|
|
c.setFont("Helvetica", 8)
|
|
c.drawString(
|
|
20 * mm, y, "Helvetica 8pt — The quick brown fox jumps over the lazy dog."
|
|
)
|
|
y -= 8 * mm
|
|
c.setFont("Helvetica", 12)
|
|
c.drawString(
|
|
20 * mm, y, "Helvetica 12pt — The quick brown fox jumps over the lazy dog."
|
|
)
|
|
y -= 10 * mm
|
|
c.setFont("Helvetica-Bold", 16)
|
|
c.drawString(20 * mm, y, "Helvetica-Bold 16pt — SHARP EDGES")
|
|
y -= 12 * mm
|
|
c.setFont("Helvetica-Oblique", 14)
|
|
c.drawString(20 * mm, y, "Helvetica-Oblique 14pt — slanted")
|
|
y -= 12 * mm
|
|
c.setFont("Courier", 12)
|
|
c.drawString(20 * mm, y, "Courier 12pt — monospace 0123456789")
|
|
y -= 12 * mm
|
|
c.setFont("Times-Roman", 14)
|
|
c.drawString(20 * mm, y, "Times-Roman 14pt — serif")
|
|
y -= 14 * mm
|
|
|
|
# Coloured text.
|
|
c.setFillColor(colors.HexColor("#C62828"))
|
|
c.setFont("Helvetica-Bold", 14)
|
|
c.drawString(20 * mm, y, "Red bold text")
|
|
c.setFillColor(colors.HexColor("#2E7D32"))
|
|
c.drawString(70 * mm, y, "Green bold text")
|
|
c.setFillColor(colors.HexColor("#1565C0"))
|
|
c.drawString(120 * mm, y, "Blue bold text")
|
|
y -= 14 * mm
|
|
|
|
# Alignment samples.
|
|
c.setFillColor(colors.black)
|
|
c.setFont("Helvetica", 10)
|
|
c.drawString(20 * mm, y, "Left aligned")
|
|
c.drawCentredString(PAGE_W / 2, y, "Centre aligned")
|
|
c.drawRightString(PAGE_W - 20 * mm, y, "Right aligned")
|
|
y -= 14 * mm
|
|
|
|
# Extended-Latin & symbols — reportlab's built-in fonts don't cover CJK,
|
|
# so we stay Latin-only to avoid needing external font files.
|
|
c.setFont("Helvetica", 10)
|
|
for line in (
|
|
"Latin-1 supplement: À Á Â Ã Ä Å Æ Ç È É Ê Ë Ì Í Î Ï",
|
|
"Symbols: € £ ¥ © ® ™ ± × ÷ ≈ ≠ ≤ ≥",
|
|
"Numerals: 0 1 2 3 4 5 6 7 8 9",
|
|
):
|
|
c.drawString(20 * mm, y, line)
|
|
y -= 8 * mm
|
|
|
|
# Filled paragraph block (coloured rectangle with clipped text).
|
|
c.setFillColor(colors.HexColor("#FFF3E0"))
|
|
c.rect(20 * mm, 30 * mm, PAGE_W - 40 * mm, 35 * mm, fill=1, stroke=1)
|
|
c.setFillColor(colors.HexColor("#333333"))
|
|
c.setFont("Helvetica", 9)
|
|
txt = c.beginText(22 * mm, 55 * mm)
|
|
for line in (
|
|
"This paragraph is drawn inside a coloured rectangle to exercise",
|
|
"anti-aliased edges, fill patterns, and clipped text rendering on the",
|
|
"PWG raster path. Any visual regression in glyph kerning, colour fill,",
|
|
"or stroke width should show up as an SSIM drop on this page.",
|
|
):
|
|
txt.textLine(line)
|
|
c.drawText(txt)
|
|
|
|
_draw_footer(c, 2)
|
|
|
|
|
|
def _page_shapes(c: canvas.Canvas) -> None:
|
|
"""Page 3: filled circles, stroked rectangles, diagonal lines, polygons."""
|
|
_draw_page_header(c, 3, "Shapes & Strokes")
|
|
|
|
# Filled circles.
|
|
cx0 = 50 * mm
|
|
cy = PAGE_H - 70 * mm
|
|
for i, (r, hv) in enumerate(
|
|
(
|
|
(14 * mm, "#FFB300"),
|
|
(11 * mm, "#039BE5"),
|
|
(8 * mm, "#6A1B9A"),
|
|
(5 * mm, "#00897B"),
|
|
)
|
|
):
|
|
c.setFillColor(colors.HexColor(hv))
|
|
c.circle(cx0 + i * 35 * mm, cy, r, fill=1, stroke=0)
|
|
|
|
# Stroked rectangles with varying line widths.
|
|
y = PAGE_H - 110 * mm
|
|
for i, w in enumerate((0.5, 1.0, 2.0, 3.5, 6.0)):
|
|
c.setStrokeColor(colors.HexColor("#37474F"))
|
|
c.setLineWidth(w)
|
|
c.rect(20 * mm + i * 32 * mm, y, 25 * mm, 15 * mm, fill=0, stroke=1)
|
|
c.setFillColor(colors.HexColor("#37474F"))
|
|
c.setFont("Helvetica", 8)
|
|
c.drawString(20 * mm + i * 32 * mm, y - 5 * mm, f"{w} pt")
|
|
|
|
# Diagonal lines.
|
|
c.setStrokeColor(colors.HexColor("#E53935"))
|
|
c.setLineWidth(1.2)
|
|
for i in range(12):
|
|
x1 = 20 * mm + i * 14 * mm
|
|
c.line(x1, 100 * mm, x1 + 10 * mm, 140 * mm)
|
|
|
|
# Triangle via path.
|
|
p = c.beginPath()
|
|
p.moveTo(60 * mm, 70 * mm)
|
|
p.lineTo(110 * mm, 70 * mm)
|
|
p.lineTo(85 * mm, 110 * mm)
|
|
p.close()
|
|
c.setFillColor(colors.HexColor("#FFD54F"))
|
|
c.setStrokeColor(colors.HexColor("#5D4037"))
|
|
c.setLineWidth(2)
|
|
c.drawPath(p, fill=1, stroke=1)
|
|
|
|
# Hexagon via path.
|
|
p = c.beginPath()
|
|
p.moveTo(120 * mm, 70 * mm)
|
|
for n in range(1, 7):
|
|
angle = 2 * math.pi * n / 6
|
|
p.lineTo(
|
|
150 * mm + 20 * mm * math.cos(angle),
|
|
90 * mm + 20 * mm * math.sin(angle),
|
|
)
|
|
p.close()
|
|
c.setFillColor(colors.HexColor("#81C784"))
|
|
c.setStrokeColor(colors.HexColor("#1B5E20"))
|
|
c.drawPath(p, fill=1, stroke=1)
|
|
|
|
_draw_footer(c, 3)
|
|
|
|
|
|
def _page_gradient(c: canvas.Canvas) -> None:
|
|
"""Page 4: faux gradients, overlapping translucent circles, grey ramp."""
|
|
_draw_page_header(c, 4, "Gradients & Overlaps")
|
|
|
|
strips = 200
|
|
x0 = 20 * mm
|
|
w = PAGE_W - 40 * mm
|
|
step = w / strips
|
|
|
|
# Horizontal gradient strip: red → yellow → green.
|
|
y0 = PAGE_H - 80 * mm
|
|
h = 30 * mm
|
|
for i in range(strips):
|
|
t = i / (strips - 1)
|
|
if t < 0.5:
|
|
u = t / 0.5
|
|
r = 0xE5 * (1 - u) + 0xFD * u
|
|
g = 0x39 * (1 - u) + 0xD8 * u
|
|
b = 0x35 * (1 - u) + 0x35 * u
|
|
else:
|
|
u = (t - 0.5) / 0.5
|
|
r = 0xFD * (1 - u) + 0x43 * u
|
|
g = 0xD8 * (1 - u) + 0xA0 * u
|
|
b = 0x35 * (1 - u) + 0x47 * u
|
|
c.setFillColorRGB(r / 255, g / 255, b / 255)
|
|
c.rect(x0 + i * step, y0, step + 0.5, h, fill=1, stroke=0)
|
|
|
|
# Vertical-ish gradient strip: blue → white.
|
|
y0 = PAGE_H - 130 * mm
|
|
for i in range(strips):
|
|
t = i / (strips - 1)
|
|
r = 0x1E * (1 - t) + 0xFF * t
|
|
g = 0x88 * (1 - t) + 0xFF * t
|
|
b = 0xE5 * (1 - t) + 0xFF * t
|
|
c.setFillColorRGB(r / 255, g / 255, b / 255)
|
|
c.rect(x0 + i * step, y0, step + 0.5, 20 * mm, fill=1, stroke=0)
|
|
|
|
# Overlapping translucent circles (alpha blending).
|
|
c.saveState()
|
|
c.setFillAlpha(0.6)
|
|
for i, hv in enumerate(("#E53935", "#1E88E5", "#43A047")):
|
|
c.setFillColor(colors.HexColor(hv))
|
|
c.circle(70 * mm + i * 25 * mm, 65 * mm, 25 * mm, fill=1, stroke=0)
|
|
c.restoreState()
|
|
|
|
# Black-to-white grayscale ramp.
|
|
ramp_y = 30 * mm
|
|
ramp_h = 12 * mm
|
|
steps = 16
|
|
sw = w / steps
|
|
for i in range(steps):
|
|
v = i / (steps - 1)
|
|
c.setFillColorRGB(v, v, v)
|
|
c.rect(20 * mm + i * sw, ramp_y, sw + 0.5, ramp_h, fill=1, stroke=0)
|
|
|
|
_draw_footer(c, 4)
|
|
|
|
|
|
def generate_pdf(out_path: Path) -> None:
|
|
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
c = canvas.Canvas(
|
|
str(out_path),
|
|
pagesize=PAGE_SIZE,
|
|
invariant=True, # strip dynamic metadata for byte-stable output
|
|
)
|
|
c.setTitle("winprint.rs visual regression fixture")
|
|
c.setAuthor("winprint.rs")
|
|
c.setSubject("Visual regression test input")
|
|
|
|
_page_color_swatches(c)
|
|
c.showPage()
|
|
_page_text_styles(c)
|
|
c.showPage()
|
|
_page_shapes(c)
|
|
c.showPage()
|
|
_page_gradient(c)
|
|
c.showPage()
|
|
|
|
c.save()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# data.tiff — Ghostscript tiff24nc
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def convert_pdf_to_tiff(pdf_path: Path, out_path: Path) -> None:
|
|
"""Render the PDF into a multi-page TIFF via Ghostscript's tiff24nc device."""
|
|
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
with tempfile.NamedTemporaryFile() as gs_out:
|
|
_run(
|
|
[
|
|
"gs",
|
|
"-dNOPAUSE",
|
|
"-dBATCH",
|
|
"-dSAFER",
|
|
"-dQUIET",
|
|
"-sDEVICE=tiffscaled24",
|
|
"-dTIFFDateTime=false",
|
|
f"-r{BASELINE_DPI*TIFF_DOWN_SCALE_FACTOR}",
|
|
f"-dDownScaleFactor={TIFF_DOWN_SCALE_FACTOR}",
|
|
f"-sOutputFile={gs_out.name}",
|
|
str(pdf_path),
|
|
]
|
|
)
|
|
_run(["tiffcp", "-c", "zip", str(gs_out.name), str(out_path)])
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# data.xps — Ghostscript xpswrite
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def convert_pdf_to_xps(pdf_path: Path, out_path: Path) -> None:
|
|
"""Convert the PDF into an XPS package via Ghostscript.
|
|
|
|
Ghostscript's xpswrite device honours SOURCE_DATE_EPOCH for the ZIP
|
|
entries' mtime, which keeps the resulting .xps byte-stable. -dSAFER
|
|
disables any surprising PostScript side-effects from the input.
|
|
"""
|
|
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
_run(
|
|
[
|
|
"gs",
|
|
"-dNOPAUSE",
|
|
"-dBATCH",
|
|
"-dSAFER",
|
|
"-dQUIET",
|
|
"-sDEVICE=xpswrite",
|
|
f"-sOutputFile={out_path}",
|
|
str(pdf_path),
|
|
]
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Utilities
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def calculate_sha256(filename):
|
|
"""Calculate the SHA256 hash of a file."""
|
|
h = hashlib.sha256()
|
|
with open(filename, "rb") as file:
|
|
# Read file in chunks to handle large files efficiently
|
|
while chunk := file.read(8192):
|
|
h.update(chunk)
|
|
return h.hexdigest()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Orchestration
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def main() -> int:
|
|
_require_tools()
|
|
|
|
VR_DIR.mkdir(parents=True, exist_ok=True)
|
|
files_before = {
|
|
p.name: calculate_sha256(p) for p in VR_DIR.iterdir() if p.is_file()
|
|
}
|
|
|
|
pdf_path = VR_DIR / "data.pdf"
|
|
tiff_path = VR_DIR / "data.tiff"
|
|
xps_path = VR_DIR / "data.xps"
|
|
|
|
print("[1/3] Generating PDF...")
|
|
generate_pdf(pdf_path)
|
|
|
|
print("[2/3] Generating TIFF...")
|
|
convert_pdf_to_tiff(pdf_path, tiff_path)
|
|
|
|
print("[3/3] Generating XPS...")
|
|
convert_pdf_to_xps(pdf_path, xps_path)
|
|
|
|
files_after = {p.name: calculate_sha256(p) for p in VR_DIR.iterdir() if p.is_file()}
|
|
|
|
print()
|
|
print("wrote under:", VR_DIR)
|
|
keys = set(files_before.keys()) | set(files_after.keys())
|
|
for name in sorted(keys):
|
|
before = files_before.get(name)
|
|
after = files_after.get(name)
|
|
if before is None:
|
|
print(f" + {name} (new)")
|
|
print(f" SHA256: {after}")
|
|
elif after is None:
|
|
print(f" - {name} (deleted)")
|
|
elif before != after:
|
|
print(f" * {name} (changed)")
|
|
print(f" SHA256: {after}")
|
|
else:
|
|
print(f" {name} (unchanged)")
|
|
print(f" SHA256: {after}")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|