From c40ea35fc3de0f224afe0312fed854f80fbd2aa9 Mon Sep 17 00:00:00 2001 From: user Date: Thu, 16 Jul 2026 10:11:55 +0200 Subject: [PATCH] feat: refine repeated dual-QR header --- scripts/check_repository.py | 6 +- tools/render_card.py | 521 ++++++++++++++++++++++-------------- 2 files changed, 318 insertions(+), 209 deletions(-) diff --git a/scripts/check_repository.py b/scripts/check_repository.py index 858dc27..5e82c0b 100644 --- a/scripts/check_repository.py +++ b/scripts/check_repository.py @@ -84,7 +84,7 @@ def check_render() -> None: tex = tex_path.read_text(encoding="utf-8") html = html_path.read_text(encoding="utf-8") - for marker in ("DEMO-01", "IP WO", "INF04", "KARTA PRACY"): + for marker in ("DEMO-01", "IP WO", "INF04", "System kart pracy"): if marker not in tex: raise AssertionError(f"TeX nie zawiera znacznika {marker!r}") if marker not in html: @@ -104,6 +104,10 @@ def check_render() -> None: for marker in ("resource-qr-repository", "resource-qr-page"): if marker not in html: raise AssertionError(f"HTML nie zawiera komórki {marker!r}") + if "PROJECT" not in html: + raise AssertionError("HTML nie zawiera nowej tabliczki projektu") + if "resource-qr-label" in html: + raise AssertionError("HTML nadal zawiera podpis pod kodem QR") for marker in ('class="title-qr"', 'class="code-subblock"'): if marker not in html: raise AssertionError(f"HTML nie zawiera znacznika {marker!r}") diff --git a/tools/render_card.py b/tools/render_card.py index 6c2ec42..cb8779e 100644 --- a/tools/render_card.py +++ b/tools/render_card.py @@ -673,9 +673,6 @@ def macro(name: str, value: str) -> str: def render_preamble(data: dict) -> list[str]: card = data["card"] header_height_mm = resource_header_height_mm(data) - source_url = repository_url(data) - doc_uuid = str((data.get("title_block") or {}).get("doc_uuid", "")) - card_number = f"{card.get('number', '')}/{card.get('count', '')}" return [ r"\documentclass[12pt,a4paper]{article}", "", @@ -687,7 +684,7 @@ def render_preamble(data: dict) -> list[str]: r"\usepackage{amsmath}", r"\usepackage{amssymb}", r"\usepackage{xcolor}", - rf"\usepackage[a4paper,left=2.15cm,right=2.15cm,top=0.5cm,bottom=0.5cm,headheight={header_height_mm:g}mm,headsep=3mm,footskip=8mm,includehead,includefoot]{{geometry}}", + rf"\usepackage[a4paper,left=2.15cm,right=2.15cm,top=0.5cm,bottom=0.5cm,headheight={header_height_mm:g}mm,headsep=3mm,includehead]{{geometry}}", r"\usepackage{eso-pic}", r"\usepackage{marginnote}", r"\usepackage{array}", @@ -742,16 +739,6 @@ def render_preamble(data: dict) -> list[str]: r"\pagestyle{fancy}", r"\fancyhf{}", r"\fancyhead[C]{\ESCPageResourceHeader}", - r"\fancyfoot[C]{%", - r" \begin{minipage}{\textwidth}%", - r" \hrule height0.35pt\vspace{0.5mm}%", - r" \fontsize{4.2}{4.8}\selectfont\ttfamily%", - r" \begin{tabularx}{\textwidth}{@{}p{27mm}p{49mm}Xr@{}}%", - rf" \textbf{{KARTA}} {tex_escape(card_number)} & \textbf{{UUID}} {tex_escape(card.get('uuid', ''))} & \textbf{{DOKUMENT}} {tex_escape(doc_uuid)} & \textbf{{ARKUSZ}} \thepage/\pageref{{LastPage}} \\%", - rf" \textbf{{WERSJA}} {tex_escape(card.get('version', ''))} · {tex_escape(card.get('revision_date', ''))} & \multicolumn{{2}}{{l}}{{\fontsize{{3.6}}{{4.1}}\selectfont\textbf{{GITEA}} \href{{{source_url}}}{{\nolinkurl{{{source_url}}}}}}} & \textbf{{AUTOR}} {tex_escape(card.get('author', ''))}%", - r" \end{tabularx}%", - r" \end{minipage}%", - r"}", r"\renewcommand{\headrulewidth}{0pt}", r"\renewcommand{\footrulewidth}{0pt}", "", @@ -3100,9 +3087,69 @@ def title_block_fields(data: dict) -> dict[str, str | bool]: def resource_header_height_mm(data: dict) -> float: - """Return the configured repeated-header height in millimetres.""" + """Return header height after removing the former QR-caption strip.""" configured = data.get("title_block") or {} - return float(configured.get("height_cm", 3)) * 10 + configured_height = float(configured.get("height_cm", 3)) * 10 + return max(23.0, configured_height - 2.0) + + +def resource_header_level_values(card: dict) -> tuple[str, str, str, str, str, str]: + """Split the curriculum code into the compact third-row fields.""" + parts = [part.strip() for part in re.split(r"\s*[·|]\s*", str(card.get("level", ""))) if part.strip()] + if len(parts) >= 5: + program, separator, ko_date = parts[0].partition("@") + subject = parts[1] + scope = parts[2] + level = re.sub(r"(?i)^kl\.\s*", "", parts[3]).strip() + position = re.sub(r"(?i)^poz\.\s*", "", parts[4]).strip() + return subject, program, ko_date if separator else "—", scope, level, position + subject = "Inf." if str(card.get("subject", "")).lower().startswith("inf") else str(card.get("subject", "")) + return subject, "—", "—", "—", "—", "—" + + +def resource_header_layout(data: dict, sheet_value: str) -> list[list[dict]]: + """Describe the four-row, 119 mm center grid shared by all renderers.""" + card = data["card"] + fields = title_block_fields(data) + subject, program, ko_date, scope, level, position = resource_header_level_values(card) + configured = data.get("title_block") or {} + card_number = f"{card.get('number', '')}/{card.get('count', '')}" + return [ + [ + {"width": 59.5, "label": "TITLE", "value": card.get("title", ""), "title": True}, + {"width": 18, "label": "VER.", "value": fields.get("revision", card.get("version", "")), "emphasis": True}, + {"width": 41.5, "label": "DATETIME", "value": fields.get("prepared_on", card.get("revision_date", ""))}, + ], + [ + {"width": 59.5, "label": "PROJECT", "value": card.get("project", ""), "title": True}, + {"width": 30, "label": "SERIES", "value": card.get("series_title", card.get("series", ""))}, + {"width": 17, "label": "CARD", "value": card_number}, + {"width": 12.5, "label": "SHEET", "value": sheet_value, "key": "sheet"}, + ], + [ + {"width": 7, "label": "SUBJ.", "value": subject, "compact": True}, + {"width": 6, "label": "PROG.", "value": program, "compact": True}, + {"width": 17.5, "label": "CORE", "value": ko_date, "compact": True}, + {"width": 11, "label": "SCOPE", "value": scope, "compact": True}, + {"width": 9, "label": "LEVEL", "value": level, "compact": True}, + {"width": 9, "label": "POS.", "value": position, "compact": True}, + { + "width": 59.5, + "kind": "identity", + "uuid_gitea": str(configured.get("url_host_uuid", card.get("uuid", ""))), + "uuid_card": str(configured.get("doc_uuid", card.get("uuid", ""))), + "author": card.get("author", ""), + }, + ], + [ + { + "width": 119, + "kind": "urls", + "repository_url": str(fields.get("repository_url", "")), + "url": str(fields.get("url", "")), + }, + ], + ] @lru_cache(maxsize=32) @@ -3219,7 +3266,7 @@ def render_page_header_html(data: dict, page: int, total: int) -> str: return ( f'' - f'{qr}{html_escape(label)}' + f'{qr}' ) return ( f'
' @@ -3233,103 +3280,95 @@ def render_page_header_html(data: dict, page: int, total: int) -> str: else "" ) - card_number = f"{card.get('number', '')}/{card.get('count', '')}" sheet_number = f"{page}/{total}" header_class = "page-resource-header page-resource-header--dual" if repository_qr_html else "page-resource-header" + layout = resource_header_layout(data, sheet_number) - def inline_fields(items: list[tuple[str, object]], strong_first: bool = False) -> str: - rendered: list[str] = [] - for index, (label, value) in enumerate(items): - if index: - rendered.append('|') - rendered.append(f'{html_escape(label)}') - tag = "strong" if strong_first and index == 0 else "span" - rendered.append(f'<{tag} class="resource-value">{html_escape(value or "—")}') - return '
' + "".join(rendered) + "
" + def render_field(field: dict) -> str: + kind = str(field.get("kind", "")) + if kind == "identity": + return ( + '
' + 'GITEA UUID ' + f'{html_escape(field.get("uuid_gitea", ""))}' + 'CARD UUID ' + f'{html_escape(field.get("uuid_card", ""))}' + f'AUTHOR  {html_escape(field.get("author", ""))}' + '
' + ) + if kind == "urls": + repository_target = str(field.get("repository_url", "")) + html_target = str(field.get("url", "")) + return ( + '' + ) + classes = ["resource-header-field"] + if field.get("title"): + classes.append("resource-header-field--title") + if field.get("url"): + classes.append("resource-header-field--url") + if field.get("emphasis"): + classes.append("resource-header-field--emphasis") + if field.get("compact"): + classes.append("resource-header-field--compact") + label = str(field.get("label", "")) + value = str(field.get("value", "") or "—") + label_html = f'{html_escape(label)}' if label else "" + value_html = f'{html_escape(value)}' + content = label_html + value_html + href = str(field.get("href", "")) + if href: + content = ( + f'' + f'{content}' + ) + return f'
{content}
' - primary_rows = [ - inline_fields( - [ - (fields["category"], f"{card_prefix(card)}: {card.get('title', '')}"), - ], - strong_first=True, - ), - inline_fields( - [ - ("Projekt", card.get("project", "")), - ("Seria", card.get("series_title", card.get("series", ""))), - ] - ), - inline_fields( - [ - ("Przedmiot", card.get("subject", "")), - ("Poziom", card.get("level", "")), - ] - ), - inline_fields([("HTML", url or "brak URL")]), - ] - meta_rows = [ - inline_fields( - [("Data", card.get("revision_date", "")), ("Status", card.get("status", ""))] - ), - inline_fields([("Karta", card_number), ("Wersja", card.get("version", ""))]), - inline_fields([("Autor", card.get("author", "")), ("Arkusz", sheet_number)]), - inline_fields([("UUID", card.get("uuid", ""))]), - ] + def render_row(row: list[dict]) -> str: + columns = " ".join(f'{field["width"]}fr' for field in row) + return ( + f'
' + f'{"".join(render_field(field) for field in row)}
' + ) + + center_rows = "".join(render_row(row) for row in layout) column_group = ( - '' + '' if repository_qr_html - else '' + else '' + ) + left_cell = ( + f'{repository_qr_html}' + if repository_qr_html + else "" ) - rows: list[str] = [] - for index, (primary, meta) in enumerate(zip(primary_rows, meta_rows)): - cells: list[str] = [] - if index == 0 and repository_qr_html: - cells.append(f'{repository_qr_html}') - cells.append(f'{primary}') - cells.append(f'{meta}') - if index == 0: - cells.append(f'{page_qr_html}') - rows.append("" + "".join(cells) + "") return ( f'
' f'{column_group}' - f'{"".join(rows)}
' + f'{left_cell}' + f'
{center_rows}
' + f'{page_qr_html}' + f'' '
' ) def render_page_footer_html(data: dict, page: int, total: int) -> str: - """Render the compact per-sheet footer used by the HTML A4 preview.""" - card = data["card"] - title_block = data.get("title_block") or {} - doc_uuid = str(title_block.get("doc_uuid", "")) - source_url = repository_url(data) - card_number = f"{card.get('number', '')}/{card.get('count', '')}" - return ( - '
' - '' - f'' - f'' - f'' - f'' - '' - f'' - f'' - f'' - '
KARTA {html_escape(card_number)}UUID {html_escape(card.get("uuid", ""))}DOKUMENT {html_escape(doc_uuid)}
WERSJA {html_escape(card.get("version", ""))} · {html_escape(card.get("revision_date", ""))}GITEA {html_escape(source_url)}
' - ) + """The compact resource header carries all repeated metadata.""" + return "" def page_resource_header_css(data: dict) -> str: return r""" .page-resource-header{width:100%;height:__RESOURCE_HEADER_HEIGHT__;min-height:__RESOURCE_HEADER_HEIGHT__;max-height:__RESOURCE_HEADER_HEIGHT__;background:#fff;color:#111;overflow:hidden;font-family:ui-monospace,SFMono-Regular,"Latin Modern Mono",Consolas,monospace;line-height:1.08} -.resource-header-table{width:100%;height:100%;border-collapse:collapse;table-layout:fixed;border:1px solid #111;font:600 5pt/1.05 ui-monospace,SFMono-Regular,"Latin Modern Mono",Consolas,monospace}.resource-header-table tr{height:25%}.resource-header-cell{padding:.25mm .65mm;border-right:1px solid #777;border-bottom:1px solid #aaa;vertical-align:middle;overflow:hidden}.resource-header-table tr:last-child .resource-header-cell{border-bottom:0}.resource-header-cell--meta{border-right:1px solid #777}.resource-inline{display:flex;align-items:baseline;gap:.55mm;min-width:0;white-space:nowrap;overflow:hidden}.resource-label{font-weight:700;text-transform:uppercase;flex:none}.resource-value{font:inherit;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.resource-header-table tr:first-child .resource-header-cell--primary .resource-value{font-weight:800;font-size:6pt}.resource-separator{color:#999;flex:none} -.resource-qr-cell{padding:0;vertical-align:middle;overflow:hidden}.resource-qr-cell--left{border-right:1px solid #777}.resource-qr-cell--right{border-left:1px solid #777}.resource-qr{box-sizing:border-box;width:100%;height:100%;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:.25mm;padding:.45mm;color:#333;text-decoration:none}.resource-qr .title-qr{display:block;width:22mm;height:22mm;max-width:22mm;max-height:22mm;flex:none}.resource-qr-label{font:700 4.5pt/1 ui-monospace,SFMono-Regular,"Latin Modern Mono",Consolas,monospace;letter-spacing:.08em;color:#333}.resource-qr-empty{text-align:center}.resource-qr-empty span,.resource-qr-empty strong{display:block;font:600 5pt/1.15 ui-monospace,SFMono-Regular,Consolas,monospace} -.page-resource-footer{position:absolute;left:21.5mm;right:21.5mm;bottom:5mm;border-top:1px solid #111;padding-top:.6mm;font:500 5pt/1.1 ui-monospace,SFMono-Regular,"Latin Modern Mono",Consolas,monospace;color:#222;overflow:hidden}.page-resource-footer table{width:100%;border-collapse:collapse;table-layout:fixed;font:inherit}.page-resource-footer td{width:25%;padding:.25mm .6mm;border:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.page-resource-footer td+td{border-left:1px solid #ddd}.page-resource-footer .footer-right{text-align:right} +.resource-header-table{width:100%;height:100%;border-collapse:collapse;table-layout:fixed;border:1px solid #111}.resource-header-table>tbody>tr{height:100%}.resource-header-center{height:100%;padding:0;vertical-align:middle;overflow:hidden}.resource-header-grid{display:grid;grid-template-rows:1fr 1fr 1fr 2fr;width:100%;height:100%}.resource-header-row{display:grid;min-width:0;min-height:0}.resource-header-field{display:flex;flex-direction:column;align-items:flex-start;justify-content:center;gap:.08mm;min-width:0;padding:.12mm .55mm;border-right:1px solid #888;border-bottom:1px solid #aaa;overflow:hidden;white-space:nowrap}.resource-header-row .resource-header-field:last-child{border-right:0}.resource-header-row:last-child .resource-header-field{border-bottom:0}.resource-header-field a{display:flex;flex-direction:column;align-items:flex-start;gap:.08mm;min-width:0;color:#111;text-decoration:none}.resource-label{font:800 2.8pt/1 ui-monospace,SFMono-Regular,"Latin Modern Mono",Consolas,monospace;text-transform:uppercase;flex:none}.resource-value{font:700 5.6pt/1 ui-monospace,SFMono-Regular,"Latin Modern Mono",Consolas,monospace;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.resource-header-field--title .resource-value{font-size:6.8pt;font-weight:800}.resource-header-field--emphasis .resource-value{font-size:5.8pt;font-weight:800}.resource-header-field--compact{gap:.05mm;padding-inline:.18mm}.resource-header-field--compact .resource-label{font-size:2.5pt}.resource-header-field--compact .resource-value{font-size:3.9pt}.resource-header-field--identity{display:flex;flex-direction:column;align-items:stretch;justify-content:center;gap:.1mm;padding:.1mm .45mm;font:700 3.25pt/1.12 ui-monospace,SFMono-Regular,"Latin Modern Mono",Consolas,monospace}.resource-header-field--identity span{display:flex;align-items:baseline;gap:.3mm;min-width:0}.resource-header-field--identity b{font-weight:800}.resource-header-field--identity .resource-author{margin-left:auto;text-align:right}.resource-header-field--urls{display:flex;flex-direction:column;align-items:stretch;justify-content:center;gap:.35mm;padding:.3mm .55mm}.resource-header-field--urls a{display:block;overflow:hidden;text-overflow:clip;font:700 4.6pt/1.05 ui-monospace,SFMono-Regular,"Latin Modern Mono",Consolas,monospace}.resource-header-field--urls b{font-weight:800} +.resource-qr-cell{height:100%;padding:0;vertical-align:middle;overflow:hidden}.resource-qr-cell--left{border-right:1px solid #777}.resource-qr-cell--right{border-left:1px solid #777}.resource-qr{box-sizing:border-box;width:100%;height:100%;display:flex;align-items:center;justify-content:center;padding:.4mm;color:#333;text-decoration:none}.resource-qr .title-qr{display:block;width:22mm;height:22mm;max-width:22mm;max-height:22mm;flex:none}.resource-qr-empty{text-align:center}.resource-qr-empty span,.resource-qr-empty strong{display:block;font:600 5pt/1.15 ui-monospace,SFMono-Regular,Consolas,monospace} """.replace("__RESOURCE_HEADER_HEIGHT__", f"{resource_header_height_mm(data):g}mm") @@ -3362,98 +3401,110 @@ def page_resource_print_css(data: dict) -> str: page_qr_background = qr_background(url, bool(fields["show_qr"])) repository_qr_enabled = bool(fields["show_repository_qr"]) repository_qr_background = qr_background(source_url, repository_qr_enabled) + sheet_token = "§" + print_layout = resource_header_layout(data, sheet_token) + row_units = [1, 1, 1, 2] + unit_height_mm = header_height_mm / sum(row_units) + row_starts_mm: list[float] = [] + current_y_mm = 0.0 + for units in row_units: + row_starts_mm.append(current_y_mm) + current_y_mm += unit_height_mm * units + grid_paths = [ + f"M0 {sum(row_units[:row_index]) * unit_height_mm:g}H119" + for row_index in range(1, 4) + ] + for row_index, row in enumerate(print_layout): + boundary = 0.0 + for field in row[:-1]: + boundary += float(field["width"]) + grid_paths.append( + f"M{boundary:g} {row_starts_mm[row_index]:g}V{row_starts_mm[row_index] + unit_height_mm * row_units[row_index]:g}" + ) center_grid_svg = ( - '' - '' - '' + f'' '' ) center_grid_data = base64.b64encode(center_grid_svg.encode("utf-8")).decode("ascii") center_grid_background = f'url("data:image/svg+xml;base64,{center_grid_data}")' - middle = "\n".join( - [ - f"{fields['category']} | {card_prefix(card)}: {card.get('title', '')}", - f"Projekt: {card.get('project', '')} · Seria: {card.get('series_title', card.get('series', ''))}", - f"Przedmiot: {card.get('subject', '')}", - f"Poziom: {card.get('level', '')}", - url, - ] - ) - meta_before_page = "\n".join( - [ - f"DATA {card.get('revision_date', '')} · STATUS {card.get('status', '')}", - f"KARTA {card.get('number', '')}/{card.get('count', '')} · WERSJA {card.get('version', '')}", - "STRONA ", - ] - ) - meta_after_page = "\n".join( - [ - f" · AUTOR {card.get('author', '')}", - f"UUID {card.get('uuid', '')}", - ] - ) qr_box = ( - f"height:{header_height_mm:g}mm;box-sizing:border-box;padding:24mm .8mm .5mm;" + f"height:{header_height_mm:g}mm;box-sizing:border-box;padding:0;" "border:1px solid #111;background-color:#fff;background-repeat:no-repeat;" - "background-position:center 1mm;background-size:22mm 22mm;" + "background-position:center;background-size:22mm 22mm;" "vertical-align:middle;text-align:center;" - "font:700 5px/1 ui-monospace,SFMono-Regular,Consolas,monospace;" - "letter-spacing:.08em;color:#444" + "font:0/0 sans-serif;color:transparent" ) - if repository_qr_enabled: - def header_row(primary: object = "", meta: object = "") -> str: - left = str(primary).replace("\n", " ")[:92] - right = str(meta).replace("\n", " ") - return f"{left:<92} {right}" - center_before_page = "\n".join( - [ - header_row( - f"{fields['category']} | {card_prefix(card)}: {card.get('title', '')}", - f"DATA {card.get('revision_date', '')} | STATUS {card.get('status', '')}", - ), - header_row( - f"PROJEKT {card.get('project', '')} | SERIA {card.get('series_title', card.get('series', ''))}", - f"KARTA {card.get('number', '')}/{card.get('count', '')} | WERSJA {card.get('version', '')}", - ), - header_row( - f"PRZEDMIOT {card.get('subject', '')} | POZIOM {card.get('level', '')}", - f"AUTOR {card.get('author', '')} | ARKUSZ ", - ), + def print_row(row: list[dict], total_characters: int) -> list[str]: + if len(row) == 1 and row[0].get("kind") == "urls": + repository = f'GITEA {row[0].get("repository_url", "")}' + html_url = f'HTML {row[0].get("url", "")}' + return [ + " " * total_characters, + repository[:total_characters].ljust(total_characters), + html_url[:total_characters].ljust(total_characters), + " " * total_characters, ] - ) - center_after_page = "\n" + header_row( - f"HTML {url}", - f"UUID {card.get('uuid', '')}", - ) - footer_left = ( - f"KARTA {card.get('number', '')}/{card.get('count', '')}\n" - f"WERSJA {card.get('version', '')} · {card.get('revision_date', '')}" - ) - footer_center = f"UUID {card.get('uuid', '')}\nGITEA {source_url}" - footer_right_before_page = "ARKUSZ " + character_widths: list[int] = [] + assigned = 0 + for field in row[:-1]: + width = max(3, round(float(field["width"]) / 119 * total_characters)) + character_widths.append(width) + assigned += width + character_widths.append(total_characters - assigned) + key_cells: list[str] = [] + value_cells: list[str] = [] + for field, width in zip(row, character_widths): + if field.get("kind") == "identity": + host_uuid = str(field.get("uuid_gitea", "")) + card_uuid = str(field.get("uuid_card", "")) + gitea_identity = f' GITEA UUID {host_uuid}' + card_identity = f' CARD UUID {card_uuid}' + author = f'AUTHOR {field.get("author", "")}' + key_cells.append(gitea_identity[:width].ljust(width)) + available = max(1, width - len(author)) + value_cells.append( + (card_identity[:available].ljust(available) + author)[:width].ljust(width) + ) + continue + label = str(field.get("label", "")) + value = str(field.get("value", "") or "—") + key_text = f" {label}" if label else "" + value_text = f" {value}" + if len(key_text) > width: + key_text = key_text[: max(1, width - 1)] + "…" + if len(value_text) > width: + value_text = value_text[: max(1, width - 1)] + "…" + key_cells.append(key_text.ljust(width)) + value_cells.append(value_text.ljust(width)) + return ["".join(key_cells), "".join(value_cells)] + + print_characters = 139 if repository_qr_enabled else 166 + print_lines = [line for row in print_layout for line in print_row(row, print_characters)] + center_text = "\n".join(print_lines) + center_before_page, center_after_page = center_text.split(sheet_token, 1) + print_line_height_mm = (header_height_mm - 0.4) / len(print_lines) + if repository_qr_enabled: return f""" @page{{ size:A4; - margin:{page_top_margin_mm:g}mm 21.5mm 15mm; - @top-left{{content:"GITEA";width:24mm;{qr_box};background-image:{repository_qr_background}}} - @top-center{{content:"{css_paged_string(center_before_page)}" counter(page) "/" counter(pages) "{css_paged_string(center_after_page)}";width:119mm;height:{header_height_mm:g}mm;box-sizing:border-box;padding:1mm;border-block:1px solid #111;background-color:#fff;background-image:{center_grid_background};background-repeat:no-repeat;background-position:center;background-size:100% 100%;white-space:pre;vertical-align:middle;text-align:left;font:600 5.3px/1.2 ui-monospace,SFMono-Regular,Consolas,monospace;color:#111;overflow:hidden}} - @top-right{{content:"HTML";width:24mm;{qr_box};background-image:{page_qr_background}}} - @bottom-left{{content:"{css_paged_string(footer_left)}";width:45mm;height:7mm;box-sizing:border-box;padding:.7mm 1mm 0;border-top:1px solid #111;white-space:pre;text-align:left;font:500 4.8px/1.2 ui-monospace,SFMono-Regular,Consolas,monospace}} - @bottom-center{{content:"{css_paged_string(footer_center)}";width:90mm;height:7mm;box-sizing:border-box;padding:.7mm 1mm 0;border-top:1px solid #111;white-space:pre;text-align:left;font:500 4.8px/1.2 ui-monospace,SFMono-Regular,Consolas,monospace;overflow:hidden}} - @bottom-right{{content:"{css_paged_string(footer_right_before_page)}" counter(page) "/" counter(pages) "{css_paged_string(chr(10) + 'AUTOR ' + str(card.get('author', '')))}";width:32mm;height:7mm;box-sizing:border-box;padding:.7mm 1mm 0;border-top:1px solid #111;white-space:pre;text-align:right;font:500 4.8px/1.2 ui-monospace,SFMono-Regular,Consolas,monospace}} + margin:{page_top_margin_mm:g}mm 21.5mm 5mm; + @top-left{{content:"";width:24mm;{qr_box};background-image:{repository_qr_background}}} + @top-center{{content:"{css_paged_string(center_before_page)}" counter(page) "/" counter(pages) "{css_paged_string(center_after_page)}";width:119mm;height:{header_height_mm:g}mm;box-sizing:border-box;padding:.2mm .7mm 0;border-block:1px solid #111;background-color:#fff;background-image:{center_grid_background};background-repeat:no-repeat;background-position:center;background-size:100% 100%;white-space:pre;vertical-align:top;text-align:left;font:700 5.2px/{print_line_height_mm:g}mm ui-monospace,SFMono-Regular,Consolas,monospace;letter-spacing:-.02px;color:#111;overflow:hidden}} + @top-right{{content:"";width:24mm;{qr_box};background-image:{page_qr_background}}} }} """ return f""" @page{{ size:A4; - margin:{page_top_margin_mm:g}mm 21.5mm 15mm; - @top-left{{content:"{css_paged_string(meta_before_page)}" counter(page) "/" counter(pages) "{css_paged_string(meta_after_page)}";width:47mm;height:{header_height_mm:g}mm;box-sizing:border-box;padding:1.5mm 1.7mm;border:1px solid #111;white-space:pre-wrap;vertical-align:middle;text-align:left;font:600 5.7px/1.3 ui-monospace,SFMono-Regular,Consolas,monospace;color:#111}} - @top-center{{content:"{css_paged_string(middle)}";width:96mm;height:{header_height_mm:g}mm;box-sizing:border-box;padding:1.5mm 2mm;border-block:1px solid #111;white-space:pre-wrap;vertical-align:middle;text-align:left;font:600 6.2px/1.25 ui-monospace,SFMono-Regular,Consolas,monospace;color:#111}} - @top-right{{content:"HTML";width:24mm;{qr_box};background-image:{page_qr_background}}} + margin:{page_top_margin_mm:g}mm 21.5mm 5mm; + @top-left{{content:"{css_paged_string(center_before_page)}" counter(page) "/" counter(pages) "{css_paged_string(center_after_page)}";width:143mm;height:{header_height_mm:g}mm;box-sizing:border-box;padding:.2mm .7mm 0;border-block:1px solid #111;border-left:1px solid #111;background-color:#fff;background-image:{center_grid_background};background-repeat:no-repeat;background-position:center;background-size:100% 100%;white-space:pre;vertical-align:top;text-align:left;font:700 5.2px/{print_line_height_mm:g}mm ui-monospace,SFMono-Regular,Consolas,monospace;letter-spacing:-.02px;color:#111;overflow:hidden}} + @top-right{{content:"";width:24mm;{qr_box};background-image:{page_qr_background}}} }} """ @@ -3483,75 +3534,129 @@ def render_page_header_tex_macro(data: dict) -> list[str]: else r"{\tiny QR wyłączony}" ) source_qr = rf"\qrcode[level=L,height=22mm]{{{source_url}}}" if dual_qr else "" - title = f"{card_prefix(card)}: {card.get('title', '')}" - card_number = f"{card.get('number', '')}/{card.get('count', '')}" - primary_width = "76mm" if dual_qr else "89mm" - meta_width = "39mm" if dual_qr else "47mm" - row_height_mm = (inner_height_mm - 0.5) / 4 + layout = resource_header_layout(data, r"\thepage/\pageref{LastPage}") + row_units = [1, 1, 1, 2] + unit_height_mm = (inner_height_mm - (3 * 0.35 * 0.35146)) / sum(row_units) + row_heights_mm = [unit_height_mm * units for units in row_units] - def header_rows_tex(rows: list[str]) -> list[str]: - output: list[str] = [] - for index, row in enumerate(rows): - output.append( - " \\parbox[c][" - + f"{row_height_mm:g}mm" - + "][c]{\\linewidth}{\\hspace{0.65mm}" - + row - + "\\hspace{0.65mm}}%" + def tex_header_cell(field: dict, height_mm: float) -> str: + kind = str(field.get("kind", "")) + if kind == "identity": + uuid_gitea = tex_escape(field.get("uuid_gitea", "")) + uuid_card = tex_escape(field.get("uuid_card", "")) + author = tex_escape(field.get("author", "")) + return ( + rf"\parbox[c][{height_mm:g}mm][c]{{\linewidth}}{{\hspace{{0.35mm}}" + rf"{{\fontsize{{3.15}}{{3.55}}\selectfont\ttfamily\bfseries GITEA UUID}} " + rf"{{\fontsize{{3.15}}{{3.55}}\selectfont\ttfamily {uuid_gitea}}}\par\nointerlineskip" + rf"\hspace{{0.35mm}}{{\fontsize{{3.15}}{{3.55}}\selectfont\ttfamily\bfseries CARD UUID}} " + rf"{{\fontsize{{3.15}}{{3.55}}\selectfont\ttfamily {uuid_card}}}" + rf"\hfill{{\fontsize{{3.15}}{{3.55}}\selectfont\ttfamily\bfseries AUTHOR {author}}}\hspace{{0.35mm}}}}" ) - if index != len(rows) - 1: - output.extend( - [ - r" \par\nointerlineskip%", - r" \hrule height0.35pt%", - r" \nointerlineskip%", - ] - ) - return output + if kind == "urls": + repository = str(field.get("repository_url", "")) + html_url = str(field.get("url", "")) + return ( + rf"\parbox[c][{height_mm:g}mm][c]{{\linewidth}}{{\hspace{{0.45mm}}" + rf"{{\fontsize{{4.15}}{{4.5}}\selectfont\ttfamily\bfseries GITEA}} " + rf"{{\fontsize{{4.15}}{{4.5}}\selectfont\ttfamily\href{{{repository}}}{{\nolinkurl{{{repository}}}}}}}\par\nointerlineskip" + rf"\hspace{{0.45mm}}{{\fontsize{{4.15}}{{4.5}}\selectfont\ttfamily\bfseries HTML}} " + rf"{{\fontsize{{4.15}}{{4.5}}\selectfont\ttfamily\href{{{html_url}}}{{\nolinkurl{{{html_url}}}}}}}}}" + ) + label = str(field.get("label", "")) + value = str(field.get("value", "") or "—") + value_tex = value if field.get("key") == "sheet" else tex_escape(value) + if field.get("title"): + value_size, value_leading = 6.8, 7.0 + elif field.get("emphasis"): + value_size, value_leading = 5.8, 6.0 + elif field.get("url"): + value_size, value_leading = 5.3, 5.6 + elif field.get("compact"): + value_size, value_leading = 3.9, 4.1 + else: + value_size, value_leading = 5.6, 5.8 + compact = bool(field.get("compact")) + label_size, label_leading = (2.5, 2.7) if compact else (2.8, 3.0) + left_padding_mm = 0.15 if compact else 0.45 + label_tex = ( + rf"{{\fontsize{{{label_size:g}}}{{{label_leading:g}}}\selectfont\ttfamily\bfseries {tex_escape(label)}}}" + if label + else "" + ) + value_content = ( + rf"{{\fontsize{{{value_size:g}}}{{{value_leading:g}}}\selectfont\ttfamily\bfseries {value_tex}}}" + ) + href = str(field.get("href", "")) + if href: + value_content = rf"\href{{{href}}}{{{value_content}}}" + return ( + rf"\parbox[c][{height_mm:g}mm][c]{{\linewidth}}{{" + rf"\hspace{{{left_padding_mm:g}mm}}{label_tex}\par\nointerlineskip\vspace{{0.12mm}}" + rf"\hspace{{{left_padding_mm:g}mm}}{value_content}\hspace{{0.15mm}}}}" + ) - primary_rows = [ - rf"{{\fontsize{{5.0}}{{5.4}}\selectfont\ttfamily {tex_escape(fields['category'])} | \textbf{{{tex_escape(title)}}}}}", - rf"{{\fontsize{{4.8}}{{5.2}}\selectfont\ttfamily \textbf{{PROJEKT}} {tex_escape(card.get('project', ''))} | \textbf{{SERIA}} {tex_escape(card.get('series_title', card.get('series', '')))}}}", - rf"{{\fontsize{{4.5}}{{5.0}}\selectfont\ttfamily \textbf{{PRZEDMIOT}} {tex_escape(card.get('subject', ''))} | \textbf{{POZIOM}} {tex_escape(card.get('level', ''))}}}", - rf"{{\fontsize{{3.8}}{{4.2}}\selectfont\ttfamily \textbf{{HTML}} \href{{{url}}}{{\nolinkurl{{{url}}}}}}}", - ] - meta_rows = [ - rf"{{\fontsize{{4.6}}{{5.0}}\selectfont\ttfamily \textbf{{DATA}} {tex_escape(card.get('revision_date', ''))} | \textbf{{STATUS}} {tex_escape(card.get('status', ''))}}}", - rf"{{\fontsize{{4.6}}{{5.0}}\selectfont\ttfamily \textbf{{KARTA}} {tex_escape(card_number)} | \textbf{{WERSJA}} {tex_escape(card.get('version', ''))}}}", - rf"{{\fontsize{{4.6}}{{5.0}}\selectfont\ttfamily \textbf{{AUTOR}} {tex_escape(card.get('author', ''))} | \textbf{{ARKUSZ}} \thepage/\pageref{{LastPage}}}}", - rf"{{\fontsize{{3.8}}{{4.2}}\selectfont\ttfamily \textbf{{UUID}} {tex_escape(card.get('uuid', ''))}}}", - ] + def tex_header_row(row: list[dict], height_mm: float) -> list[str]: + fixed_columns = "|".join( + # The vertical rule belongs to the physical width of each fixed + # segment. Subtract it from the p-column itself so cumulative + # boundaries (especially the 59.5 mm midpoint) stay aligned even + # when a row contains a different number of subcolumns. + f'p{{\\dimexpr{float(field["width"]) / 119:.8f}\\linewidth-\\arrayrulewidth\\relax}}' + for field in row[:-1] + ) + column_spec = f"@{{}}{fixed_columns}|X@{{}}" if fixed_columns else "@{}X@{}" + cells = " & ".join(tex_header_cell(field, height_mm) for field in row) + return [ + rf" \begin{{tabularx}}{{\linewidth}}{{{column_spec}}}%", + rf" {cells} \\%", + r" \end{tabularx}%", + ] + + center_rows: list[str] = [] + for index, row in enumerate(layout): + center_rows.extend(tex_header_row(row, row_heights_mm[index])) + if index != len(layout) - 1: + center_rows.extend( + [ + r" \par\nointerlineskip%", + r" \hrule height0.35pt%", + r" \nointerlineskip%", + ] + ) source_cell = ( [ rf" \begin{{minipage}}[c][{inner_height_mm:g}mm][c]{{24mm}}\centering%", - " " + source_qr + r"\par%", - r" \vspace{0.2mm}{\fontsize{4.5}{4.8}\selectfont\bfseries\ttfamily GITEA}%", + " " + source_qr + r"%", r" \end{minipage}%", - r" \vrule width0.35pt\hspace{1mm}%", + r" \vrule width0.35pt%", ] if dual_qr else [] ) + center_width = ( + r"\dimexpr\linewidth-48mm-0.7pt\relax" + if dual_qr + else r"\dimexpr\linewidth-24mm-0.35pt\relax" + ) return [ r"\newcommand{\ESCPageResourceHeader}{%", r" \begingroup%", r" \setlength{\parskip}{0pt}%", r" \setlength{\fboxsep}{0pt}%", r" \setlength{\fboxrule}{0.35pt}%", + r" \setlength{\arrayrulewidth}{0.35pt}%", r" \noindent\fbox{%", rf" \begin{{minipage}}[c][{outer_height_mm:g}mm][c]{{\dimexpr\textwidth-2\fboxrule\relax}}%", *source_cell, - rf" \begin{{minipage}}[c][{inner_height_mm:g}mm][c]{{{primary_width}}}%", - *header_rows_tex(primary_rows), + rf" \begin{{minipage}}[c][{inner_height_mm:g}mm][c]{{{center_width}}}%", + r" \setlength{\tabcolsep}{0pt}%", + r" \renewcommand{\arraystretch}{0}%", + *center_rows, r" \end{minipage}%", - r" \hfill\vrule width0.35pt\hspace{1mm}%", - rf" \begin{{minipage}}[c][{inner_height_mm:g}mm][c]{{{meta_width}}}%", - *header_rows_tex(meta_rows), - r" \end{minipage}%", - r" \hfill\vrule width0.35pt\hspace{1mm}%", + r" \vrule width0.35pt%", rf" \begin{{minipage}}[c][{inner_height_mm:g}mm][c]{{24mm}}\centering%", - " " + page_qr + r"\par%", - r" \vspace{0.2mm}{\fontsize{4.5}{4.8}\selectfont\bfseries\ttfamily HTML}%", + " " + page_qr + r"%", r" \end{minipage}%", r" \end{minipage}%", r" }%",