from __future__ import annotations
import hashlib
import math
import os
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path
from PIL import Image
from reportlab.lib import colors
from reportlab.lib.pagesizes import A4
from reportlab.lib.utils import ImageReader
from reportlab.pdfgen import canvas
from reportlab.lib.units import mm
SCRIPT_DIR = Path(__file__).resolve().parent
REPO_ROOT = SCRIPT_DIR.parent.parent
VR_DIR = REPO_ROOT / "test_data" / "visual_regression"
BASELINE_DPI = 300
TIFF_DOWN_SCALE_FACTOR = 4
PAGE_SIZE = tuple(math.floor(side * BASELINE_DPI / 72) * 72 / BASELINE_DPI for side in A4)
PAGE_W, PAGE_H = PAGE_SIZE
os.environ.setdefault("SOURCE_DATE_EPOCH", "1700000000")
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:
subprocess.run(cmd, capture_output=True, text=True, check=True)
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:
_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:
_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
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
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
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
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:
_draw_page_header(c, 3, "Shapes & Strokes")
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)
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")
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)
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)
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:
_draw_page_header(c, 4, "Gradients & Overlaps")
strips = 200
x0 = 20 * mm
w = PAGE_W - 40 * mm
step = w / strips
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)
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)
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()
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 _page_raster_detail(c: canvas.Canvas) -> None:
_draw_page_header(c, 5, "Raster Detail (300 dpi)")
margin_x = 36
gap_x = 18
gap_y = 36
top_y = PAGE_H - 108
bottom_y = 54
width_px = int((PAGE_W - 2 * margin_x - gap_x) / 2 * BASELINE_DPI / 72)
height_px = int((top_y - bottom_y - gap_y) / 2 * BASELINE_DPI / 72)
width_pt = width_px * 72 / BASELINE_DPI
height_pt = height_px * 72 / BASELINE_DPI
patterns = (
("Vertical, 2 px", lambda x, y: x // 2),
("Horizontal, 2 px", lambda x, y: y // 2),
("Checkerboard, 3 px", lambda x, y: x // 3 + y // 3),
("Diagonal, 3 px", lambda x, y: (x + y) // 3),
)
for i, (label, band) in enumerate(patterns):
pixels = bytes(
255 * (band(x, y) % 2)
for y in range(height_px)
for x in range(width_px)
)
img = Image.frombytes("L", (width_px, height_px), pixels)
x = margin_x + (i % 2) * (width_pt + gap_x)
top = top_y - (i // 2) * (height_pt + gap_y)
c.setFillColor(colors.black)
c.setFont("Helvetica", 10)
c.drawString(x, top + 12, label)
c.drawImage(ImageReader(img), x, top - height_pt, width_pt, height_pt)
_draw_footer(c, 5)
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, )
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()
_page_raster_detail(c)
c.showPage()
c.save()
def convert_pdf_to_tiff(pdf_path: Path, out_path: Path) -> None:
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)])
def convert_pdf_to_xps(pdf_path: Path, out_path: Path) -> None:
out_path.parent.mkdir(parents=True, exist_ok=True)
_run(
[
"gs",
"-dNOPAUSE",
"-dBATCH",
"-dSAFER",
"-dQUIET",
"-sDEVICE=xpswrite",
f"-r{BASELINE_DPI}",
f"-sOutputFile={out_path}",
str(pdf_path),
]
)
def calculate_sha256(filename):
h = hashlib.sha256()
with open(filename, "rb") as file:
while chunk := file.read(8192):
h.update(chunk)
return h.hexdigest()
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())