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
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_DPI = 300
TIFF_DOWN_SCALE_FACTOR = 4
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 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()
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"-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())