Bitácora 8–30 jun: contrato firmado, plan de actividades y skill proposal-pdf
- Comunicaciones (REGISTRO #15–#21): respuesta v1.1 (10-jun), luz verde (16-jun), contrato (envío 25-jun, ajustes + firma 26-jun), arranque con Erika y kickoff 1-jul - Evidencia en fuentes/ (correos 8/10/16-jun, WhatsApp Paola y Erika) - Contratos (sin firmar y firmado) en propuesta/ - Plan de actividades Etapa 0–3 + guion del kickoff (planeacion/) - Skill proposal-pdf instalado (.claude/skills) + Propuesta-Balam.pdf regenerada - README/PENDIENTES al día; limpieza de archivos sueltos; .gitignore (locks de Office) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,228 @@
|
||||
---
|
||||
name: proposal-pdf
|
||||
description: >-
|
||||
Build polished, print-optimized PDF proposals, quotes, and reports with an
|
||||
editorial design system (navy + terracotta, serif display headings, full-bleed
|
||||
cover, auto-numbered table of contents, stamped confidential footers). Use this
|
||||
skill WHENEVER the user wants a professional/branded PDF deliverable from
|
||||
content — e.g. "make this proposal a PDF", "generate a commercial proposal /
|
||||
cotización / propuesta", "turn this into a nice client-facing PDF", "build a
|
||||
quote/report PDF", or any request for a high-quality multi-page PDF document
|
||||
that needs a cover, table of contents, tables, or page numbers. Prefer this
|
||||
over ad-hoc HTML-to-PDF or reportlab-from-scratch whenever presentation quality
|
||||
matters. Works in Spanish or English.
|
||||
---
|
||||
|
||||
# proposal-pdf
|
||||
|
||||
Produces a refined, print-ready PDF from hand-authored HTML using headless
|
||||
Chromium, with an automatically numbered table of contents and stamped footers.
|
||||
This is the same pipeline used to build real commercial proposals — it favors
|
||||
typographic quality over speed.
|
||||
|
||||
## Why this approach
|
||||
|
||||
Tools like `reportlab`-from-scratch or `pandoc` give you a document but not a
|
||||
*designed* one. This skill instead has you author one print-optimized HTML file
|
||||
against a ready-made design system, then renders it with Chromium (which has the
|
||||
best CSS print engine available). Two things that are otherwise painful are
|
||||
handled for you:
|
||||
|
||||
- **Full-bleed cover** — a dark cover that runs edge-to-edge, via `@page:first { margin:0 }`.
|
||||
- **Real TOC page numbers** — resolved in a second render pass by searching the
|
||||
rendered PDF, so they're always correct regardless of how content paginates.
|
||||
|
||||
## Setup (once)
|
||||
|
||||
```bash
|
||||
pip install playwright pdfplumber pypdf reportlab
|
||||
python -m playwright install chromium
|
||||
```
|
||||
|
||||
### Fonts — bundled and embedded (no system install needed)
|
||||
|
||||
The design uses **Caladea** (serif display), **Carlito** (sans body) and
|
||||
**DejaVu Sans Mono** (code). These ship with the skill in `assets/fonts/`, and
|
||||
`template.html` embeds them with `@font-face` rules that point at a `fonts/`
|
||||
folder **next to the HTML**. The output therefore does not depend on which fonts
|
||||
happen to be installed on the build machine. Rules of the road:
|
||||
|
||||
- When you copy `template.html` to your working file, **also copy `assets/fonts/`
|
||||
next to it** so the `url("fonts/…")` paths resolve. `build_pdf.py` renders each
|
||||
document from its own folder, so relative paths (fonts, logos, images) just work.
|
||||
- If the fonts are missing at build time, Chromium silently falls back to system
|
||||
fonts (Cambria/Calibri/…) — it looks *close* but isn't identical. After building,
|
||||
`build_pdf.py` inspects the finished PDF and **prints a warning** if Caladea or
|
||||
Carlito didn't embed, so that regression can't ship unnoticed.
|
||||
- Caladea/Carlito are *metric-compatible* with Cambria/Calibri: swapping them
|
||||
changes the glyph shapes but **not** the pagination. So if a rebuild has a
|
||||
different page count than an older PDF, the **content** changed — not the fonts.
|
||||
|
||||
The footer text is stamped separately with a TTF set in the config
|
||||
(`fonts.footer_ttf`); if that path doesn't exist it falls back to Helvetica.
|
||||
|
||||
### En este proyecto (Windows / Balam)
|
||||
|
||||
El skill ya está instalado en `.claude/skills/proposal-pdf/`. Para usarlo aquí:
|
||||
|
||||
```powershell
|
||||
# 1) Dependencias (una vez)
|
||||
pip install playwright pdfplumber pypdf reportlab
|
||||
python -m playwright install chromium
|
||||
|
||||
# 2) Construir (desde la raíz del repo)
|
||||
python .claude/skills/proposal-pdf/scripts/build_pdf.py <ruta>/pdf.config.json --check # valida
|
||||
python .claude/skills/proposal-pdf/scripts/build_pdf.py <ruta>/pdf.config.json # construye
|
||||
```
|
||||
|
||||
- **Fuentes:** las fuentes del diseño (Caladea/Carlito/DejaVu Sans Mono) van **embebidas** vía `@font-face` desde `propuesta/fonts/`, así que el PDF sale idéntico sin depender de lo que tenga instalado Windows. No instales nada. Para el **texto del footer** (que se estampa aparte con reportlab) sí se usa una TTF del sistema: `fonts.footer_ttf` apunta a `C:\Windows\Fonts\calibri.ttf`. Si al reconstruir ves el aviso `⚠ design font(s) missing`, es que la carpeta `fonts/` no quedó junto al HTML.
|
||||
- **Verificación visual:** en este entorno **no hay `pdftoppm`/poppler**, así que el paso "verify visually" se hace con el helper incluido, que usa `pypdfium2` (ya viene con `pdfplumber`):
|
||||
```powershell
|
||||
python .claude/skills/proposal-pdf/scripts/render_check.py <ruta>/Salida.pdf 1 2 3 # rasteriza páginas a PNG
|
||||
```
|
||||
Luego abre los PNG (o pídeme que los lea). Siempre revisa: portada full-bleed, números de TOC presentes y plausibles, tablas que no se desborden, y que el footer aparezca en el cuerpo pero **no** en la portada.
|
||||
- **Consola UTF-8:** el script ya fuerza salida UTF-8 (la consola de Windows es cp1252 y truena con los glifos ✓/⚠). No necesitas hacer nada.
|
||||
|
||||
## Files in this skill
|
||||
|
||||
```
|
||||
proposal-pdf/
|
||||
├── SKILL.md
|
||||
├── scripts/
|
||||
│ └── build_pdf.py # the render → number → stamp engine
|
||||
├── assets/
|
||||
│ ├── template.html # the design system + every component, with examples
|
||||
│ ├── fonts/ # bundled design fonts (Caladea, Carlito, DejaVu Sans Mono)
|
||||
│ ├── pdf.config.example.json # the full config schema (documented)
|
||||
│ └── test.config.json # smoke test for the template
|
||||
└── examples/ # a second, complete worked example
|
||||
├── example-a4.html # A4 + cover logo + a long page-spanning table
|
||||
└── example-a4.config.json # shows A4, "X / N" footer, skip_pages
|
||||
```
|
||||
|
||||
Smoke-test the install end-to-end:
|
||||
```bash
|
||||
python3 scripts/build_pdf.py assets/test.config.json # Letter template
|
||||
python3 scripts/build_pdf.py examples/example-a4.config.json # A4 worked example
|
||||
```
|
||||
|
||||
## Workflow
|
||||
|
||||
### 1. Author the content as HTML
|
||||
|
||||
Copy `assets/template.html` to a working file (e.g. `proposal.html`) and replace
|
||||
the example content with the real content. **Keep the CSS and the component
|
||||
markup/classes** — that's the design system. The big in-file comment and the
|
||||
component cheat-sheet at the top of the template tell you which class does what.
|
||||
Common building blocks: numbered section headers (`.sec-head` + `.sec-num`),
|
||||
tables with `td.k` / `td.num` / `tr.total` / `tr.sub-total` / `td.cov`, accent
|
||||
panels (`.callout`, `.callout.cool`), a pipeline diagram (`.flow` + `.chip`),
|
||||
headline stat cards (`.synthesis` + `.stat`), and phase blocks (`.etapa`).
|
||||
|
||||
For each section that appears in the table of contents:
|
||||
- put a `{{PG_<key>}}` token in its TOC row (already wired in the template), and
|
||||
- remember a **unique phrase from that section's body** (see step 2).
|
||||
|
||||
Appendices/new-page sections get the `.break` class to start on a fresh page.
|
||||
|
||||
### 2. Write the config
|
||||
|
||||
Copy `assets/pdf.config.example.json` to `pdf.config.json` (next to your HTML)
|
||||
and fill it in. The important part is the `toc` map: each key matches a
|
||||
`{{PG_<key>}}` token, and its value is a phrase the engine will search for to
|
||||
find that section's page.
|
||||
|
||||
> **Anchor rules — read this, it prevents the two failure modes:**
|
||||
> 1. **Use BODY text, never the section title.** Titles also render in the TOC,
|
||||
> so a title would match the TOC page first and give the wrong number.
|
||||
> 2. **Don't start the anchor on the first letter of a drop-cap paragraph.** The
|
||||
> CSS drop cap splits the first letter into its own glyph, so the extracted
|
||||
> text reads `"T his…"`. Start the anchor a word or two in
|
||||
> (e.g. `"opening paragraph…"`, not `"This opening paragraph…"`).
|
||||
>
|
||||
> Good anchor: the first ~6–10 words of the first normal paragraph after the
|
||||
> heading. Keep it distinctive.
|
||||
|
||||
### 3. Build
|
||||
|
||||
```bash
|
||||
python scripts/build_pdf.py pdf.config.json --check # validate config + env first (optional)
|
||||
python scripts/build_pdf.py pdf.config.json # build
|
||||
```
|
||||
|
||||
`--check` runs a **preflight**: confirms the deps and Chromium are installed, the
|
||||
input HTML exists, and — importantly — that every `{{PG_*}}` token in the HTML
|
||||
has a matching anchor in `toc` (and warns about anchors with no token). Fix any
|
||||
reported issue before building.
|
||||
|
||||
The build renders once with tokens blanked, prints the resolved page numbers,
|
||||
fills the TOC, re-renders, stamps footers, sets metadata, and writes the PDF. If
|
||||
an anchor can't be found it stops and tells you exactly which one. If an anchor
|
||||
matches more than one page it warns and uses the first — make it more specific if
|
||||
that's wrong.
|
||||
|
||||
### 4. ALWAYS verify visually
|
||||
|
||||
Rendering bugs are visual, so look at the result. Rasterize a few pages and
|
||||
inspect them (don't just trust that it ran):
|
||||
|
||||
```bash
|
||||
pdftoppm -png -r 80 -f 1 -l 1 Output.pdf check_cover # full-bleed cover
|
||||
pdftoppm -png -r 80 -f 2 -l 2 Output.pdf check_toc # TOC page numbers filled
|
||||
# …and a content page or two
|
||||
```
|
||||
|
||||
Check: the cover fills the page edge-to-edge, the TOC numbers are present and
|
||||
plausible, tables don't overflow, and the footer shows on body pages but not the
|
||||
cover. Re-author and rebuild as needed — iteration is normal.
|
||||
|
||||
## Customizing
|
||||
|
||||
- **Colors / fonts:** edit the `:root` CSS variables in your HTML (`--ink`,
|
||||
`--accent`, `--cream`, the font stacks). One accent color used sparingly reads
|
||||
as more premium than many.
|
||||
- **Page size:** set `"page_size"` to `"Letter"`, `"Legal"`, `"A4"`, `"A3"`, or a
|
||||
custom `{"width_mm":210,"height_mm":297}`. The engine derives the footer
|
||||
geometry automatically — you do **not** edit the script. For A4/A3 also change
|
||||
`@page { size:Letter }` to the matching size in the HTML CSS (there's a comment
|
||||
there). See `examples/example-a4.html` for a full A4 document.
|
||||
- **Cover logo:** drop a `<div class="logo">` into the cover `.top` — either a
|
||||
text mark (`<span class="txt">NAME</span>`) or an image
|
||||
(`<img class="invert" src="logo.png">`; `.invert` whitens a dark logo on the
|
||||
navy cover). Example in `examples/example-a4.html`.
|
||||
- **Footer:** all in `footer` — `text`, `rule` (hairline on/off), `font_size_pt`,
|
||||
`margin_mm` (distance from the bottom), `color` ([r,g,b] 0–1),
|
||||
`page_number_format` (`"{page}"`, `"{page} / {pages}"`, `"Página {page}"`),
|
||||
`skip_first_page`, and `skip_pages` (extra 1-based pages to skip, e.g. the TOC).
|
||||
- **No cover / no TOC:** a doc without `{{PG_*}}` tokens just skips the numbering
|
||||
pass (leave `toc` empty `{}`). Set `footer.skip_first_page` to `false` if
|
||||
there's no cover.
|
||||
- **Long tables & page breaks:** handled for you — table headers repeat on every
|
||||
page a table spans, rows/callouts/cards never split, and headings won't be
|
||||
stranded at a page bottom. Wrap anything else you want kept together in
|
||||
`class="keep"`; force a new page with `class="break"`.
|
||||
|
||||
## Design principles baked in (keep these)
|
||||
|
||||
- One serif for display (cover, section numbers, headings), one sans for body,
|
||||
one mono for code. Don't add more families.
|
||||
- A single accent color (terracotta) for rules, section numbers, and the one
|
||||
callout per section that matters. Everything else is navy/ink and warm neutrals.
|
||||
- Generous hairlines and uppercase micro-labels instead of heavy boxes.
|
||||
- Numeric table columns are right-aligned with tabular figures (`.num`); the
|
||||
total row is the only emphasized row.
|
||||
- Keep prose tight. The format rewards restraint.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **"Could not locate these TOC anchors"** → the phrase isn't body text on that
|
||||
page, or it starts on a drop-cap letter, or whitespace differs. Pick a longer
|
||||
distinctive body phrase. (Whitespace is normalized automatically.)
|
||||
- **Cover not full-bleed** → confirm `@page:first { margin:0 }` is present and
|
||||
the build runs with Chromium margins at 0 (it does by default). The cover
|
||||
`.cover` element is sized to the full page (216mm × 279mm for Letter).
|
||||
- **Emoji not in color** (✅⚠️❌ in coverage tables) → install a color emoji font
|
||||
(`fonts-noto-color-emoji` on Debian/Ubuntu). Chromium uses it automatically.
|
||||
- **Footer font looks wrong** → set a valid `fonts.footer_ttf`; otherwise it
|
||||
uses Helvetica.
|
||||
- **Playwright errors about browsers** → run `python -m playwright install chromium`.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"_comment": "Copy to pdf.config.json next to your HTML and edit. Relative paths resolve against THIS file. Run a dry validation with: python3 scripts/build_pdf.py pdf.config.json --check",
|
||||
|
||||
"input_html": "proposal.html",
|
||||
"output_pdf": "Proposal.pdf",
|
||||
|
||||
"_comment_page": "page_size: 'Letter' | 'Legal' | 'A4' | 'A3', or a custom object {\"width_mm\":210,\"height_mm\":297}. For A4 also set @page size:A4 in the HTML CSS.",
|
||||
"page_size": "Letter",
|
||||
"cover_full_bleed": true,
|
||||
"render_timeout_ms": 30000,
|
||||
|
||||
"metadata": {
|
||||
"title": "Commercial Proposal — Project Title",
|
||||
"author": "Your Name",
|
||||
"subject": "One-line description of the proposal",
|
||||
"keywords": "proposal, optional"
|
||||
},
|
||||
|
||||
"footer": {
|
||||
"_comment": "Stamped on body pages. skip_first_page hides it on the cover; skip_pages is extra 1-based pages to skip (e.g. the TOC). page_number_format supports {page} and {pages}, e.g. '{page}' or '{page} / {pages}' or 'Pagina {page}'.",
|
||||
"text": "Commercial Proposal · Project Title · Client — Confidential",
|
||||
"skip_first_page": true,
|
||||
"skip_pages": [],
|
||||
"page_number_format": "{page}",
|
||||
"rule": true,
|
||||
"font_size_pt": 7.5,
|
||||
"margin_mm": 12,
|
||||
"color": [0.46, 0.51, 0.57]
|
||||
},
|
||||
|
||||
"fonts": {
|
||||
"_comment": "TTF for the footer text. Falls back to Helvetica if the path is missing. Debian/Ubuntu: fonts-crosextra-carlito installs this path.",
|
||||
"footer_ttf": "/usr/share/fonts/truetype/crosextra/Carlito-Regular.ttf"
|
||||
},
|
||||
|
||||
"toc": {
|
||||
"_comment": "key -> a UNIQUE phrase from that section's BODY (NOT its title; titles also render in the TOC and match there first). Avoid the first letter of a drop-cap paragraph. Match the keys to the {{PG_*}} tokens in the HTML. Leave this empty {} for a document with no table of contents.",
|
||||
"execsum": "opening paragraph uses a drop cap",
|
||||
"1": "A lede paragraph introduces the section",
|
||||
"2": "Body text for the scope section",
|
||||
"anexoA": "Appendices start on a fresh page"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
<!DOCTYPE html>
|
||||
<!--
|
||||
template.html — editorial proposal/report design system.
|
||||
|
||||
HOW TO USE
|
||||
• Replace the example content with yours. Keep the component markup/classes.
|
||||
• Every section that appears in the TOC gets a {{PG_key}} token in the TOC row
|
||||
AND a matching entry in pdf.config.json -> "toc" whose value is a UNIQUE
|
||||
phrase from that section's BODY (never the title — titles also render in the
|
||||
TOC and would match there first). build_pdf.py fills the tokens automatically.
|
||||
• The first page is a full-bleed cover thanks to @page:first { margin:0 }.
|
||||
• Footer (confidential line + page number) is stamped by build_pdf.py, not here.
|
||||
|
||||
COMPONENT CHEAT-SHEET (classes you can reuse)
|
||||
.cover .eyebrow/.rule/h1/.client/.meta-grid → cover page
|
||||
.toc + .toc-row(.section) → table of contents
|
||||
.sec + .sec-head/.sec-num/.kick → numbered section header
|
||||
.sec-head.no-num → header with no big number
|
||||
h3 > span.sn → sub-section heading (e.g. 2.1)
|
||||
table / th / td.k / td.num / td.cov / .ctr → tables (key cell, numeric, coverage)
|
||||
tr.total / tr.sub-total → emphasized table rows
|
||||
table.compact → tighter table for dense data
|
||||
.callout / .callout.cool → accent / navy info panels
|
||||
.flow + .chip(.accent) → pipeline / step diagram
|
||||
.synthesis + .stat → headline stat cards
|
||||
p.lede → opening paragraph
|
||||
p.drop → paragraph with drop cap
|
||||
p.mini-label → small uppercase label
|
||||
.etapa + .etapa-h → phase/stage block
|
||||
.signoff → closing signature
|
||||
.cover .logo (.txt | img.invert) → optional logo on the cover
|
||||
.break → start the block on a new page
|
||||
.keep / .no-break → keep a block from splitting across pages
|
||||
-->
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="author" content="Your Name">
|
||||
<title>Proposal Title</title>
|
||||
<style>
|
||||
/* ===========================================================================
|
||||
DESIGN FONTS — embedded, so output does NOT depend on system-installed fonts.
|
||||
The .ttf files ship in the skill at assets/fonts/. When you copy this template
|
||||
to your working file, copy that fonts/ folder next to your HTML too (so these
|
||||
url("fonts/…") paths resolve). Without it the PDF silently falls back to
|
||||
Cambria/Calibri and won't match the intended look — build_pdf.py warns when
|
||||
that happens. You don't need to edit this block.
|
||||
=========================================================================== */
|
||||
@font-face{ font-family:"Caladea"; font-style:normal; font-weight:400; src:url("fonts/Caladea-Regular.ttf") format("truetype"); }
|
||||
@font-face{ font-family:"Caladea"; font-style:normal; font-weight:700; src:url("fonts/Caladea-Bold.ttf") format("truetype"); }
|
||||
@font-face{ font-family:"Caladea"; font-style:italic; font-weight:400; src:url("fonts/Caladea-Italic.ttf") format("truetype"); }
|
||||
@font-face{ font-family:"Caladea"; font-style:italic; font-weight:700; src:url("fonts/Caladea-BoldItalic.ttf") format("truetype"); }
|
||||
@font-face{ font-family:"Carlito"; font-style:normal; font-weight:400; src:url("fonts/Carlito-Regular.ttf") format("truetype"); }
|
||||
@font-face{ font-family:"Carlito"; font-style:normal; font-weight:700; src:url("fonts/Carlito-Bold.ttf") format("truetype"); }
|
||||
@font-face{ font-family:"Carlito"; font-style:italic; font-weight:400; src:url("fonts/Carlito-Italic.ttf") format("truetype"); }
|
||||
@font-face{ font-family:"Carlito"; font-style:italic; font-weight:700; src:url("fonts/Carlito-BoldItalic.ttf") format("truetype"); }
|
||||
@font-face{ font-family:"DejaVu Sans Mono"; font-style:normal; font-weight:400; src:url("fonts/DejaVuSansMono.ttf") format("truetype"); }
|
||||
|
||||
:root{
|
||||
--ink:#1C2B39; /* deep navy — primary text + cover */
|
||||
--ink-soft:#33424F; /* secondary text */
|
||||
--muted:#6B7682; /* captions, footers */
|
||||
--accent:#BC5B3E; /* terracotta — the single accent */
|
||||
--accent-d:#9E4A30; /* darker terracotta for emphasis on tint */
|
||||
--accent-tint:#F6E9E3; /* faint terracotta fill */
|
||||
--cream:#F4EFEA; /* warm panel fill */
|
||||
--line:#D9DEE3; /* hairlines */
|
||||
--serif:"Caladea", Georgia, "Times New Roman", serif;
|
||||
--sans:"Carlito", "Helvetica Neue", Arial, sans-serif;
|
||||
--mono:"DejaVu Sans Mono", "SFMono-Regular", Consolas, monospace;
|
||||
}
|
||||
|
||||
/* For A4: change size to A4 here AND set "page_size":"A4" in pdf.config.json. */
|
||||
@page{ size:Letter; margin:15mm 16mm 18mm 16mm; }
|
||||
@page :first{ margin:0; } /* full-bleed cover */
|
||||
|
||||
*{ box-sizing:border-box; }
|
||||
html,body{ margin:0; padding:0; }
|
||||
body{
|
||||
font-family:var(--sans); color:var(--ink-soft);
|
||||
font-size:10pt; line-height:1.5;
|
||||
-webkit-print-color-adjust:exact; print-color-adjust:exact;
|
||||
}
|
||||
p{ margin:0 0 7pt; }
|
||||
strong,b{ color:var(--ink); font-weight:700; }
|
||||
em{ font-style:italic; }
|
||||
code{
|
||||
font-family:var(--mono); font-size:8.6pt;
|
||||
background:var(--cream); padding:.5pt 3pt; border-radius:2pt; color:var(--ink);
|
||||
}
|
||||
a{ color:var(--accent-d); text-decoration:none; }
|
||||
|
||||
/* ---------- COVER ---------- */
|
||||
.cover{
|
||||
width:216mm; min-height:279mm; background:var(--ink); color:#EAEDF0;
|
||||
padding:30mm 26mm 24mm; display:flex; flex-direction:column;
|
||||
position:relative; overflow:hidden;
|
||||
}
|
||||
.cover::after{ /* faint geometric corner motif */
|
||||
content:""; position:absolute; right:-60mm; top:-60mm;
|
||||
width:150mm; height:150mm; border-radius:50%;
|
||||
background:radial-gradient(circle at center, rgba(188,91,62,.18), rgba(188,91,62,0) 70%);
|
||||
}
|
||||
.cover .top,.cover .mid,.cover .meta{ position:relative; z-index:2; }
|
||||
.cover .rule{ width:46pt; height:3pt; background:var(--accent); margin-bottom:14pt; }
|
||||
.cover .eyebrow{
|
||||
font-size:9pt; letter-spacing:.32em; text-transform:uppercase; color:#9FB0BF; font-weight:700;
|
||||
}
|
||||
/* Optional logo slot (top of cover). Use a text mark or an <img>.
|
||||
.invert flips a dark logo to white for the navy cover. */
|
||||
.cover .logo{ margin-bottom:16pt; }
|
||||
.cover .logo .txt{ font-family:var(--serif); font-weight:700; font-size:14pt; color:#fff; letter-spacing:.01em; }
|
||||
.cover .logo img{ height:30pt; width:auto; }
|
||||
.cover .logo img.invert{ filter:brightness(0) invert(1); opacity:.92; }
|
||||
.cover .mid{ margin-top:auto; margin-bottom:auto; padding:18mm 0; }
|
||||
.cover h1{
|
||||
font-family:var(--serif); font-weight:700; font-size:37pt; line-height:1.06;
|
||||
letter-spacing:-.01em; margin:0; color:#FFFFFF; max-width:150mm;
|
||||
}
|
||||
.cover .client{ margin-top:16pt; font-family:var(--sans); font-size:12.5pt; color:#C9D1D8; }
|
||||
.cover .client b{ color:var(--accent); font-weight:700; }
|
||||
.cover .confidential{
|
||||
margin-top:18pt; display:flex; justify-content:space-between;
|
||||
font-size:8pt; letter-spacing:.18em; text-transform:uppercase; color:#7E8C99;
|
||||
}
|
||||
.meta-grid{ display:grid; grid-template-columns:34mm 1fr; gap:6pt 10pt; margin:0; font-size:9.2pt; }
|
||||
.meta-grid dt{ color:#8A98A5; text-transform:uppercase; letter-spacing:.12em; font-size:7.6pt; padding-top:1.5pt; }
|
||||
.meta-grid dd{ margin:0; color:#D7DDE2; }
|
||||
.meta-grid dd .who{ display:block; }
|
||||
.meta-grid dd .who b{ color:#FFFFFF; font-weight:700; }
|
||||
.meta-grid dd .who span{ color:#9FB0BF; }
|
||||
|
||||
/* ---------- TOC ---------- */
|
||||
.toc{ break-before:page; padding-top:6mm; }
|
||||
.kick{ font-size:8.5pt; letter-spacing:.28em; text-transform:uppercase; color:var(--accent); font-weight:700; }
|
||||
.toc h2{ font-family:var(--serif); font-size:26pt; font-weight:700; color:var(--ink); margin:2pt 0 4pt; }
|
||||
.toc .bar{ width:40pt; height:3pt; background:var(--accent); margin:6pt 0 16pt; }
|
||||
.toc-row{ display:flex; align-items:baseline; gap:8pt; padding:7pt 0; border-bottom:.6pt solid var(--line); font-size:10.5pt; }
|
||||
.toc-row .n{ width:22pt; color:var(--accent); font-weight:700; font-variant-numeric:tabular-nums; }
|
||||
.toc-row .t{ color:var(--ink); }
|
||||
.toc-row.section .t{ font-weight:700; }
|
||||
.toc-row .dots{ flex:1; border-bottom:1pt dotted var(--line); transform:translateY(-3pt); }
|
||||
.toc-row .pg{ color:var(--muted); font-variant-numeric:tabular-nums; }
|
||||
|
||||
/* ---------- SECTIONS ---------- */
|
||||
.content{ break-before:page; }
|
||||
.sec{ margin-bottom:14pt; }
|
||||
.sec-head{ display:flex; gap:12pt; align-items:flex-start; border-bottom:1.4pt solid var(--ink); padding-bottom:6pt; margin:0 0 11pt; }
|
||||
.sec-head .sec-num{ font-family:var(--serif); font-size:30pt; font-weight:700; line-height:.9; color:var(--accent); min-width:42pt; }
|
||||
.sec-head h2{ font-family:var(--serif); font-size:19pt; font-weight:700; color:var(--ink); margin:4pt 0 0; }
|
||||
.sec-head .kick{ display:block; margin-bottom:2pt; }
|
||||
.sec-head.no-num{ border-bottom-width:1.4pt; }
|
||||
.sec-head.no-num h2{ margin-top:0; }
|
||||
|
||||
h3{ font-family:var(--sans); font-size:11.5pt; font-weight:700; color:var(--ink); margin:13pt 0 5pt; }
|
||||
h3 .sn{ color:var(--accent); font-weight:700; margin-right:7pt; }
|
||||
h4{ font-family:var(--sans); font-size:9.5pt; font-weight:700; color:var(--ink-soft); text-transform:uppercase; letter-spacing:.06em; margin:10pt 0 3pt; }
|
||||
|
||||
p.lede{ font-size:11pt; color:var(--ink-soft); }
|
||||
p.drop::first-letter{
|
||||
font-family:var(--serif); font-size:34pt; font-weight:700; color:var(--accent);
|
||||
float:left; line-height:.82; padding:2pt 6pt 0 0;
|
||||
}
|
||||
p.mini-label{ font-size:8pt; letter-spacing:.12em; text-transform:uppercase; color:var(--accent-d); font-weight:700; margin:9pt 0 1pt; }
|
||||
|
||||
ul,ol{ margin:4pt 0 8pt; padding-left:16pt; }
|
||||
li{ margin:0 0 3.5pt; padding-left:2pt; }
|
||||
li::marker{ color:var(--accent); }
|
||||
|
||||
/* ---------- TABLES ---------- */
|
||||
table{ width:100%; border-collapse:collapse; margin:10pt 0; font-size:9.2pt; }
|
||||
thead th{
|
||||
text-align:left; font-size:7.8pt; letter-spacing:.07em; text-transform:uppercase;
|
||||
color:#FFFFFF; background:var(--ink); padding:5pt 8pt; font-weight:700;
|
||||
}
|
||||
thead th.num,thead th.ctr{ text-align:right; }
|
||||
thead th.ctr{ text-align:center; }
|
||||
tbody td{ padding:5.5pt 8pt; border-bottom:.6pt solid var(--line); vertical-align:top; }
|
||||
tbody tr:nth-child(even) td{ background:#FBFAF8; }
|
||||
td.k{ color:var(--ink); font-weight:600; }
|
||||
.num,th.num{ text-align:right; font-variant-numeric:tabular-nums; white-space:nowrap; }
|
||||
tr.total td{ font-weight:700; color:var(--ink); background:var(--cream); border-top:1.2pt solid var(--ink); border-bottom:1.2pt solid var(--ink); }
|
||||
tr.sub-total td{ font-weight:700; color:var(--ink); background:var(--accent-tint); border-top:1pt solid var(--accent); }
|
||||
td.cov{ font-size:11pt; text-align:center; line-height:1; }
|
||||
table.compact tbody td{ padding:4pt 8pt; }
|
||||
table.compact{ font-size:8.8pt; }
|
||||
|
||||
/* ---------- CALLOUTS ---------- */
|
||||
.callout{
|
||||
background:var(--accent-tint); border-left:3pt solid var(--accent);
|
||||
padding:9pt 12pt; margin:10pt 0; font-size:9.4pt; color:var(--ink-soft); border-radius:0 3pt 3pt 0;
|
||||
}
|
||||
.callout strong,.callout b{ color:var(--accent-d); }
|
||||
.callout.cool{ background:var(--cream); border-left-color:var(--ink); }
|
||||
.callout.cool strong,.callout.cool b{ color:var(--ink); }
|
||||
|
||||
/* ---------- FLOW / PIPELINE ---------- */
|
||||
.flow{ display:flex; flex-wrap:wrap; align-items:center; gap:5pt; margin:10pt 0; }
|
||||
.flow .chip{
|
||||
background:#fff; border:1pt solid var(--line); border-radius:4pt;
|
||||
padding:4pt 9pt; font-size:8.6pt; font-weight:600; color:var(--ink-soft); white-space:nowrap;
|
||||
}
|
||||
.flow .chip.accent{ background:var(--accent); border-color:var(--accent); color:#fff; }
|
||||
.flow .arrow{ color:var(--accent); font-weight:700; }
|
||||
|
||||
/* ---------- SYNTHESIS STATS ---------- */
|
||||
.synthesis{ display:flex; gap:10pt; margin:12pt 0; }
|
||||
.synthesis .stat{ flex:1; background:var(--ink); color:#EAEDF0; border-radius:5pt; padding:11pt 13pt; }
|
||||
.synthesis .stat .big{ font-family:var(--serif); font-size:21pt; font-weight:700; color:#fff; line-height:1; }
|
||||
.synthesis .stat .lab{ font-size:8pt; letter-spacing:.08em; text-transform:uppercase; color:#9FB0BF; margin-top:4pt; }
|
||||
|
||||
/* ---------- PHASE / STAGE ---------- */
|
||||
.etapa{ border:1pt solid var(--line); border-left:3pt solid var(--accent); border-radius:0 4pt 4pt 0; padding:9pt 12pt; margin:8pt 0; }
|
||||
.etapa-h{ font-weight:700; color:var(--ink); font-size:10pt; margin-bottom:3pt; }
|
||||
.etapa-h .tag{ float:right; font-size:8pt; color:var(--muted); font-weight:600; }
|
||||
|
||||
/* ---------- SIGNOFF ---------- */
|
||||
.signoff{ margin-top:18pt; padding-top:10pt; border-top:1.4pt solid var(--ink); }
|
||||
.signoff .nm{ font-family:var(--serif); font-size:14pt; font-weight:700; color:var(--ink); }
|
||||
.signoff .rl{ font-size:8.5pt; color:var(--muted); letter-spacing:.04em; }
|
||||
|
||||
/* ---------- PRINT SAFETY (keep these) ---------- */
|
||||
/* Don't strand a heading at the bottom of a page, just above its body. */
|
||||
h2,h3,h4,.sec-head{ break-after:avoid; }
|
||||
/* Repeat table header rows when a long table spans a page break, and never
|
||||
split an individual row, callout, card, phase block or the sign-off. */
|
||||
thead{ display:table-header-group; }
|
||||
tfoot{ display:table-footer-group; }
|
||||
tr{ break-inside:avoid; }
|
||||
.callout,.synthesis,.synthesis .stat,.etapa,.flow,.signoff,figure,img{ break-inside:avoid; }
|
||||
/* Utilities: .keep keeps a block together; .break starts a new page. */
|
||||
.keep,.no-break{ break-inside:avoid; }
|
||||
.break{ break-before:page; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- ============================= COVER ============================= -->
|
||||
<section class="cover">
|
||||
<div class="top">
|
||||
<!-- Optional logo: <div class="logo"><span class="txt">YOUR MARK</span></div>
|
||||
or <div class="logo"><img class="invert" src="logo.png" alt="Logo"></div>
|
||||
(use file:// abs path or a path next to the HTML; .invert whitens a dark logo) -->
|
||||
<div class="rule"></div>
|
||||
<div class="eyebrow">Commercial Proposal</div>
|
||||
</div>
|
||||
|
||||
<div class="mid">
|
||||
<h1>Project Title<br>Goes Here</h1>
|
||||
<div class="client"><b>Client Name</b></div>
|
||||
</div>
|
||||
|
||||
<div class="meta">
|
||||
<dl class="meta-grid">
|
||||
<dt>Prepared by</dt>
|
||||
<dd>Your Name — Role · City</dd>
|
||||
<dt>Date</dt>
|
||||
<dd>1 January 2026</dd>
|
||||
<dt>Version</dt>
|
||||
<dd>1.0</dd>
|
||||
<dt>Valid for</dt>
|
||||
<dd>30 calendar days from issue</dd>
|
||||
</dl>
|
||||
<div class="confidential">
|
||||
<span>Confidential document</span>
|
||||
<span>Valid 30 days</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============================= TOC ============================= -->
|
||||
<!-- One .toc-row per section. The {{PG_key}} token is resolved by build_pdf.py. -->
|
||||
<section class="toc">
|
||||
<div class="kick">Contents</div>
|
||||
<h2>Index</h2>
|
||||
<div class="bar"></div>
|
||||
|
||||
<div class="toc-row"><span class="n">—</span><span class="t">Executive summary</span><span class="dots"></span><span class="pg">{{PG_execsum}}</span></div>
|
||||
<div class="toc-row section"><span class="n">01</span><span class="t">Understanding</span><span class="dots"></span><span class="pg">{{PG_1}}</span></div>
|
||||
<div class="toc-row section"><span class="n">02</span><span class="t">Scope</span><span class="dots"></span><span class="pg">{{PG_2}}</span></div>
|
||||
<div class="toc-row section"><span class="n">A</span><span class="t">Appendix A — Traceability</span><span class="dots"></span><span class="pg">{{PG_anexoA}}</span></div>
|
||||
</section>
|
||||
|
||||
<!-- ============================= BODY ============================= -->
|
||||
<main class="content">
|
||||
|
||||
<!-- ===== Executive summary: header with NO big number + drop cap ===== -->
|
||||
<section class="sec">
|
||||
<div class="sec-head no-num"><div><h2>Executive summary</h2></div></div>
|
||||
<p class="drop">This opening paragraph uses a drop cap. Keep the executive summary tight: the problem, the proposed first step, and the outcome. The first sentence here doubles as a good TOC anchor — pick a distinctive phrase and put it in the config.</p>
|
||||
|
||||
<div class="synthesis">
|
||||
<div class="stat"><div class="big">6–7</div><div class="lab">weeks</div></div>
|
||||
<div class="stat"><div class="big">$XX K</div><div class="lab">investment + tax</div></div>
|
||||
<div class="stat"><div class="big">4</div><div class="lab">milestones</div></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ===== Section 1: numbered header + flow diagram + callout ===== -->
|
||||
<section class="sec">
|
||||
<div class="sec-head"><div class="sec-num">01</div><div><h2>Understanding</h2></div></div>
|
||||
<p class="lede">A lede paragraph introduces the section in a slightly larger size. Distinctive opening text makes a reliable TOC anchor.</p>
|
||||
|
||||
<h3><span class="sn">1.1</span>The pipeline</h3>
|
||||
<div class="flow">
|
||||
<span class="chip">Source</span><span class="arrow">→</span>
|
||||
<span class="chip">Transform</span><span class="arrow">→</span>
|
||||
<span class="chip accent">Platform</span><span class="arrow">→</span>
|
||||
<span class="chip">Output</span>
|
||||
</div>
|
||||
|
||||
<div class="callout"><strong>Key risk to validate.</strong> Use accent callouts for the one or two things the reader must not miss. Use the <code>.cool</code> variant for neutral notes.</div>
|
||||
</section>
|
||||
|
||||
<!-- ===== Section 2: tables (stack + total row) + phase blocks ===== -->
|
||||
<section class="sec">
|
||||
<div class="sec-head"><div class="sec-num">02</div><div><h2>Scope</h2></div></div>
|
||||
<p>Body text for the scope section, with a distinctive opening phrase for the anchor.</p>
|
||||
|
||||
<table>
|
||||
<thead><tr><th>Layer</th><th>Technology</th><th class="num">Version</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="k">Backend</td><td>.NET</td><td class="num">10</td></tr>
|
||||
<tr><td class="k">Frontend</td><td>Angular</td><td class="num">21</td></tr>
|
||||
<tr><td class="k">Database</td><td>PostgreSQL</td><td class="num">16</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="etapa"><div class="etapa-h">Stage 0 — Discovery<span class="tag">~1 week</span></div>Short description of the stage and its deliverable.</div>
|
||||
<div class="etapa"><div class="etapa-h">Stage 1 — Core<span class="tag">~2 weeks</span></div>Short description of the stage and its deliverable.</div>
|
||||
|
||||
<table>
|
||||
<thead><tr><th>Stage</th><th class="num">Hours</th><th class="num">Investment (MXN)</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="k">Discovery</td><td class="num">18 – 22</td><td class="num">$10,800 – $13,200</td></tr>
|
||||
<tr><td class="k">Core</td><td class="num">32 – 39</td><td class="num">$19,200 – $23,400</td></tr>
|
||||
<tr class="total"><td>Total</td><td class="num">50 – 61 h</td><td class="num">$30,000 – $36,600</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<!-- ===== Appendix A: annex header + coverage table (emoji column) ===== -->
|
||||
<section class="sec break">
|
||||
<div class="sec-head"><div class="sec-num">A</div><div><span class="kick">Appendix</span><h2>Traceability</h2></div></div>
|
||||
<p class="lede">Appendices start on a fresh page via the <code>.break</code> class. Distinctive opening phrase for the anchor here too.</p>
|
||||
|
||||
<div class="callout cool"><strong>Legend:</strong> ✅ Covered · ⚠️ Partial · ❌ Deferred</div>
|
||||
|
||||
<table class="compact">
|
||||
<thead><tr><th style="width:46pt;">ID</th><th>Requirement</th><th class="ctr" style="width:62pt;">Coverage</th><th>Notes</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="k">RF-01</td><td>Example requirement</td><td class="cov">✅</td><td>Included</td></tr>
|
||||
<tr><td class="k">RF-02</td><td>Another requirement</td><td class="cov">⚠️</td><td>Partial in MVP</td></tr>
|
||||
<tr><td class="k">RF-03</td><td>A deferred requirement</td><td class="cov">❌</td><td>Later phase</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="signoff">
|
||||
<div class="nm">Your Name</div>
|
||||
<div class="rl">Role · City</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"_comment": "Smoke test: builds the template itself so you can verify the skill works end-to-end. Run: python3 scripts/build_pdf.py assets/test.config.json",
|
||||
"input_html": "template.html",
|
||||
"output_pdf": "test_out.pdf",
|
||||
"metadata": { "title": "Test Proposal", "author": "Test Author", "subject": "Smoke test" },
|
||||
"footer": { "text": "Commercial Proposal · Test · Client — Confidential", "skip_first_page": true },
|
||||
"fonts": { "footer_ttf": "/usr/share/fonts/truetype/crosextra/Carlito-Regular.ttf" },
|
||||
"toc": {
|
||||
"_comment": "anchors must be unique BODY phrases, never section titles",
|
||||
"execsum": "opening paragraph uses a drop cap",
|
||||
"1": "A lede paragraph introduces the section",
|
||||
"2": "Body text for the scope section",
|
||||
"anexoA": "Appendices start on a fresh page"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"_comment": "Worked example: A4 + cover logo + a long table that spans a page break + 'X / N' footer. Run: python3 scripts/build_pdf.py examples/example-a4.config.json",
|
||||
"input_html": "example-a4.html",
|
||||
"output_pdf": "Propuesta-Reservaciones-A4.pdf",
|
||||
"page_size": "A4",
|
||||
"cover_full_bleed": true,
|
||||
"metadata": {
|
||||
"title": "Propuesta — Plataforma de Reservaciones",
|
||||
"author": "Johann Velázquez",
|
||||
"subject": "Plataforma de reservaciones para Náutica del Norte",
|
||||
"keywords": "propuesta, reservaciones, software"
|
||||
},
|
||||
"footer": {
|
||||
"_comment": "skip_pages also skips the TOC (page 2) here; page_number_format shows X / N.",
|
||||
"text": "Propuesta · Plataforma de Reservaciones · Náutica del Norte — Confidencial",
|
||||
"skip_first_page": true,
|
||||
"skip_pages": [2],
|
||||
"page_number_format": "{page} / {pages}",
|
||||
"rule": true
|
||||
},
|
||||
"fonts": {
|
||||
"footer_ttf": "/usr/share/fonts/truetype/crosextra/Carlito-Regular.ttf"
|
||||
},
|
||||
"toc": {
|
||||
"execsum": "El objetivo de la primera etapa es centralizar",
|
||||
"1": "El reto principal del proyecto",
|
||||
"2": "El alcance se organiza en módulos",
|
||||
"3": "El proyecto se entrega bajo un esquema",
|
||||
"anexoA": "Este anexo detalla la cobertura"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
<!DOCTYPE html>
|
||||
<!--
|
||||
template.html — editorial proposal/report design system.
|
||||
|
||||
HOW TO USE
|
||||
• Replace the example content with yours. Keep the component markup/classes.
|
||||
• Every section that appears in the TOC gets a {{PG_key}} token in the TOC row
|
||||
AND a matching entry in pdf.config.json -> "toc" whose value is a UNIQUE
|
||||
phrase from that section's BODY (never the title — titles also render in the
|
||||
TOC and would match there first). build_pdf.py fills the tokens automatically.
|
||||
• The first page is a full-bleed cover thanks to @page:first { margin:0 }.
|
||||
• Footer (confidential line + page number) is stamped by build_pdf.py, not here.
|
||||
|
||||
COMPONENT CHEAT-SHEET (classes you can reuse)
|
||||
.cover .eyebrow/.rule/h1/.client/.meta-grid → cover page
|
||||
.toc + .toc-row(.section) → table of contents
|
||||
.sec + .sec-head/.sec-num/.kick → numbered section header
|
||||
.sec-head.no-num → header with no big number
|
||||
h3 > span.sn → sub-section heading (e.g. 2.1)
|
||||
table / th / td.k / td.num / td.cov / .ctr → tables (key cell, numeric, coverage)
|
||||
tr.total / tr.sub-total → emphasized table rows
|
||||
table.compact → tighter table for dense data
|
||||
.callout / .callout.cool → accent / navy info panels
|
||||
.flow + .chip(.accent) → pipeline / step diagram
|
||||
.synthesis + .stat → headline stat cards
|
||||
p.lede → opening paragraph
|
||||
p.drop → paragraph with drop cap
|
||||
p.mini-label → small uppercase label
|
||||
.etapa + .etapa-h → phase/stage block
|
||||
.signoff → closing signature
|
||||
.cover .logo (.txt | img.invert) → optional logo on the cover
|
||||
.break → start the block on a new page
|
||||
.keep / .no-break → keep a block from splitting across pages
|
||||
-->
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="author" content="Johann Velázquez">
|
||||
<title>Propuesta — Plataforma de Reservaciones</title>
|
||||
<style>
|
||||
:root{
|
||||
--ink:#1C2B39; /* deep navy — primary text + cover */
|
||||
--ink-soft:#33424F; /* secondary text */
|
||||
--muted:#6B7682; /* captions, footers */
|
||||
--accent:#BC5B3E; /* terracotta — the single accent */
|
||||
--accent-d:#9E4A30; /* darker terracotta for emphasis on tint */
|
||||
--accent-tint:#F6E9E3; /* faint terracotta fill */
|
||||
--cream:#F4EFEA; /* warm panel fill */
|
||||
--line:#D9DEE3; /* hairlines */
|
||||
--serif:"Caladea", Georgia, "Times New Roman", serif;
|
||||
--sans:"Carlito", "Helvetica Neue", Arial, sans-serif;
|
||||
--mono:"DejaVu Sans Mono", "SFMono-Regular", Consolas, monospace;
|
||||
}
|
||||
|
||||
/* For A4: change size to A4 here AND set "page_size":"A4" in pdf.config.json. */
|
||||
@page{ size:A4; margin:15mm 16mm 18mm 16mm; }
|
||||
@page :first{ margin:0; } /* full-bleed cover */
|
||||
|
||||
*{ box-sizing:border-box; }
|
||||
html,body{ margin:0; padding:0; }
|
||||
body{
|
||||
font-family:var(--sans); color:var(--ink-soft);
|
||||
font-size:10pt; line-height:1.5;
|
||||
-webkit-print-color-adjust:exact; print-color-adjust:exact;
|
||||
}
|
||||
p{ margin:0 0 7pt; }
|
||||
strong,b{ color:var(--ink); font-weight:700; }
|
||||
em{ font-style:italic; }
|
||||
code{
|
||||
font-family:var(--mono); font-size:8.6pt;
|
||||
background:var(--cream); padding:.5pt 3pt; border-radius:2pt; color:var(--ink);
|
||||
}
|
||||
a{ color:var(--accent-d); text-decoration:none; }
|
||||
|
||||
/* ---------- COVER ---------- */
|
||||
.cover{
|
||||
width:216mm; min-height:279mm; background:var(--ink); color:#EAEDF0;
|
||||
padding:30mm 26mm 24mm; display:flex; flex-direction:column;
|
||||
position:relative; overflow:hidden;
|
||||
}
|
||||
.cover::after{ /* faint geometric corner motif */
|
||||
content:""; position:absolute; right:-60mm; top:-60mm;
|
||||
width:150mm; height:150mm; border-radius:50%;
|
||||
background:radial-gradient(circle at center, rgba(188,91,62,.18), rgba(188,91,62,0) 70%);
|
||||
}
|
||||
.cover .top,.cover .mid,.cover .meta{ position:relative; z-index:2; }
|
||||
.cover .rule{ width:46pt; height:3pt; background:var(--accent); margin-bottom:14pt; }
|
||||
.cover .eyebrow{
|
||||
font-size:9pt; letter-spacing:.32em; text-transform:uppercase; color:#9FB0BF; font-weight:700;
|
||||
}
|
||||
/* Optional logo slot (top of cover). Use a text mark or an <img>.
|
||||
.invert flips a dark logo to white for the navy cover. */
|
||||
.cover .logo{ margin-bottom:16pt; }
|
||||
.cover .logo .txt{ font-family:var(--serif); font-weight:700; font-size:14pt; color:#fff; letter-spacing:.01em; }
|
||||
.cover .logo img{ height:30pt; width:auto; }
|
||||
.cover .logo img.invert{ filter:brightness(0) invert(1); opacity:.92; }
|
||||
.cover .mid{ margin-top:auto; margin-bottom:auto; padding:18mm 0; }
|
||||
.cover h1{
|
||||
font-family:var(--serif); font-weight:700; font-size:37pt; line-height:1.06;
|
||||
letter-spacing:-.01em; margin:0; color:#FFFFFF; max-width:150mm;
|
||||
}
|
||||
.cover .client{ margin-top:16pt; font-family:var(--sans); font-size:12.5pt; color:#C9D1D8; }
|
||||
.cover .client b{ color:var(--accent); font-weight:700; }
|
||||
.cover .confidential{
|
||||
margin-top:18pt; display:flex; justify-content:space-between;
|
||||
font-size:8pt; letter-spacing:.18em; text-transform:uppercase; color:#7E8C99;
|
||||
}
|
||||
.meta-grid{ display:grid; grid-template-columns:34mm 1fr; gap:6pt 10pt; margin:0; font-size:9.2pt; }
|
||||
.meta-grid dt{ color:#8A98A5; text-transform:uppercase; letter-spacing:.12em; font-size:7.6pt; padding-top:1.5pt; }
|
||||
.meta-grid dd{ margin:0; color:#D7DDE2; }
|
||||
.meta-grid dd .who{ display:block; }
|
||||
.meta-grid dd .who b{ color:#FFFFFF; font-weight:700; }
|
||||
.meta-grid dd .who span{ color:#9FB0BF; }
|
||||
|
||||
/* ---------- TOC ---------- */
|
||||
.toc{ break-before:page; padding-top:6mm; }
|
||||
.kick{ font-size:8.5pt; letter-spacing:.28em; text-transform:uppercase; color:var(--accent); font-weight:700; }
|
||||
.toc h2{ font-family:var(--serif); font-size:26pt; font-weight:700; color:var(--ink); margin:2pt 0 4pt; }
|
||||
.toc .bar{ width:40pt; height:3pt; background:var(--accent); margin:6pt 0 16pt; }
|
||||
.toc-row{ display:flex; align-items:baseline; gap:8pt; padding:7pt 0; border-bottom:.6pt solid var(--line); font-size:10.5pt; }
|
||||
.toc-row .n{ width:22pt; color:var(--accent); font-weight:700; font-variant-numeric:tabular-nums; }
|
||||
.toc-row .t{ color:var(--ink); }
|
||||
.toc-row.section .t{ font-weight:700; }
|
||||
.toc-row .dots{ flex:1; border-bottom:1pt dotted var(--line); transform:translateY(-3pt); }
|
||||
.toc-row .pg{ color:var(--muted); font-variant-numeric:tabular-nums; }
|
||||
|
||||
/* ---------- SECTIONS ---------- */
|
||||
.content{ break-before:page; }
|
||||
.sec{ margin-bottom:14pt; }
|
||||
.sec-head{ display:flex; gap:12pt; align-items:flex-start; border-bottom:1.4pt solid var(--ink); padding-bottom:6pt; margin:0 0 11pt; }
|
||||
.sec-head .sec-num{ font-family:var(--serif); font-size:30pt; font-weight:700; line-height:.9; color:var(--accent); min-width:42pt; }
|
||||
.sec-head h2{ font-family:var(--serif); font-size:19pt; font-weight:700; color:var(--ink); margin:4pt 0 0; }
|
||||
.sec-head .kick{ display:block; margin-bottom:2pt; }
|
||||
.sec-head.no-num{ border-bottom-width:1.4pt; }
|
||||
.sec-head.no-num h2{ margin-top:0; }
|
||||
|
||||
h3{ font-family:var(--sans); font-size:11.5pt; font-weight:700; color:var(--ink); margin:13pt 0 5pt; }
|
||||
h3 .sn{ color:var(--accent); font-weight:700; margin-right:7pt; }
|
||||
h4{ font-family:var(--sans); font-size:9.5pt; font-weight:700; color:var(--ink-soft); text-transform:uppercase; letter-spacing:.06em; margin:10pt 0 3pt; }
|
||||
|
||||
p.lede{ font-size:11pt; color:var(--ink-soft); }
|
||||
p.drop::first-letter{
|
||||
font-family:var(--serif); font-size:34pt; font-weight:700; color:var(--accent);
|
||||
float:left; line-height:.82; padding:2pt 6pt 0 0;
|
||||
}
|
||||
p.mini-label{ font-size:8pt; letter-spacing:.12em; text-transform:uppercase; color:var(--accent-d); font-weight:700; margin:9pt 0 1pt; }
|
||||
|
||||
ul,ol{ margin:4pt 0 8pt; padding-left:16pt; }
|
||||
li{ margin:0 0 3.5pt; padding-left:2pt; }
|
||||
li::marker{ color:var(--accent); }
|
||||
|
||||
/* ---------- TABLES ---------- */
|
||||
table{ width:100%; border-collapse:collapse; margin:10pt 0; font-size:9.2pt; }
|
||||
thead th{
|
||||
text-align:left; font-size:7.8pt; letter-spacing:.07em; text-transform:uppercase;
|
||||
color:#FFFFFF; background:var(--ink); padding:5pt 8pt; font-weight:700;
|
||||
}
|
||||
thead th.num,thead th.ctr{ text-align:right; }
|
||||
thead th.ctr{ text-align:center; }
|
||||
tbody td{ padding:5.5pt 8pt; border-bottom:.6pt solid var(--line); vertical-align:top; }
|
||||
tbody tr:nth-child(even) td{ background:#FBFAF8; }
|
||||
td.k{ color:var(--ink); font-weight:600; }
|
||||
.num,th.num{ text-align:right; font-variant-numeric:tabular-nums; white-space:nowrap; }
|
||||
tr.total td{ font-weight:700; color:var(--ink); background:var(--cream); border-top:1.2pt solid var(--ink); border-bottom:1.2pt solid var(--ink); }
|
||||
tr.sub-total td{ font-weight:700; color:var(--ink); background:var(--accent-tint); border-top:1pt solid var(--accent); }
|
||||
td.cov{ font-size:11pt; text-align:center; line-height:1; }
|
||||
table.compact tbody td{ padding:4pt 8pt; }
|
||||
table.compact{ font-size:8.8pt; }
|
||||
|
||||
/* ---------- CALLOUTS ---------- */
|
||||
.callout{
|
||||
background:var(--accent-tint); border-left:3pt solid var(--accent);
|
||||
padding:9pt 12pt; margin:10pt 0; font-size:9.4pt; color:var(--ink-soft); border-radius:0 3pt 3pt 0;
|
||||
}
|
||||
.callout strong,.callout b{ color:var(--accent-d); }
|
||||
.callout.cool{ background:var(--cream); border-left-color:var(--ink); }
|
||||
.callout.cool strong,.callout.cool b{ color:var(--ink); }
|
||||
|
||||
/* ---------- FLOW / PIPELINE ---------- */
|
||||
.flow{ display:flex; flex-wrap:wrap; align-items:center; gap:5pt; margin:10pt 0; }
|
||||
.flow .chip{
|
||||
background:#fff; border:1pt solid var(--line); border-radius:4pt;
|
||||
padding:4pt 9pt; font-size:8.6pt; font-weight:600; color:var(--ink-soft); white-space:nowrap;
|
||||
}
|
||||
.flow .chip.accent{ background:var(--accent); border-color:var(--accent); color:#fff; }
|
||||
.flow .arrow{ color:var(--accent); font-weight:700; }
|
||||
|
||||
/* ---------- SYNTHESIS STATS ---------- */
|
||||
.synthesis{ display:flex; gap:10pt; margin:12pt 0; }
|
||||
.synthesis .stat{ flex:1; background:var(--ink); color:#EAEDF0; border-radius:5pt; padding:11pt 13pt; }
|
||||
.synthesis .stat .big{ font-family:var(--serif); font-size:21pt; font-weight:700; color:#fff; line-height:1; }
|
||||
.synthesis .stat .lab{ font-size:8pt; letter-spacing:.08em; text-transform:uppercase; color:#9FB0BF; margin-top:4pt; }
|
||||
|
||||
/* ---------- PHASE / STAGE ---------- */
|
||||
.etapa{ border:1pt solid var(--line); border-left:3pt solid var(--accent); border-radius:0 4pt 4pt 0; padding:9pt 12pt; margin:8pt 0; }
|
||||
.etapa-h{ font-weight:700; color:var(--ink); font-size:10pt; margin-bottom:3pt; }
|
||||
.etapa-h .tag{ float:right; font-size:8pt; color:var(--muted); font-weight:600; }
|
||||
|
||||
/* ---------- SIGNOFF ---------- */
|
||||
.signoff{ margin-top:18pt; padding-top:10pt; border-top:1.4pt solid var(--ink); }
|
||||
.signoff .nm{ font-family:var(--serif); font-size:14pt; font-weight:700; color:var(--ink); }
|
||||
.signoff .rl{ font-size:8.5pt; color:var(--muted); letter-spacing:.04em; }
|
||||
|
||||
/* ---------- PRINT SAFETY (keep these) ---------- */
|
||||
/* Don't strand a heading at the bottom of a page, just above its body. */
|
||||
h2,h3,h4,.sec-head{ break-after:avoid; }
|
||||
/* Repeat table header rows when a long table spans a page break, and never
|
||||
split an individual row, callout, card, phase block or the sign-off. */
|
||||
thead{ display:table-header-group; }
|
||||
tfoot{ display:table-footer-group; }
|
||||
tr{ break-inside:avoid; }
|
||||
.callout,.synthesis,.synthesis .stat,.etapa,.flow,.signoff,figure,img{ break-inside:avoid; }
|
||||
/* Utilities: .keep keeps a block together; .break starts a new page. */
|
||||
.keep,.no-break{ break-inside:avoid; }
|
||||
.break{ break-before:page; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- ============================= COVER ============================= -->
|
||||
<section class="cover">
|
||||
<div class="top">
|
||||
<div class="logo"><span class="txt">BRÚJULA</span></div>
|
||||
<div class="rule"></div>
|
||||
<div class="eyebrow">Propuesta Comercial</div>
|
||||
</div>
|
||||
|
||||
<div class="mid">
|
||||
<h1>Plataforma de<br>Reservaciones</h1>
|
||||
<div class="client">Preparada para <b>Náutica del Norte</b></div>
|
||||
</div>
|
||||
|
||||
<div class="meta">
|
||||
<dl class="meta-grid">
|
||||
<dt>Preparada por</dt>
|
||||
<dd>Johann Velázquez — Consultor de Software · Monterrey, N.L.</dd>
|
||||
<dt>Fecha</dt>
|
||||
<dd>16 de junio de 2026</dd>
|
||||
<dt>Versión</dt>
|
||||
<dd>1.0</dd>
|
||||
<dt>Vigencia</dt>
|
||||
<dd>30 días naturales a partir de la fecha de emisión</dd>
|
||||
</dl>
|
||||
<div class="confidential">
|
||||
<span>Documento confidencial</span>
|
||||
<span>Tamaño A4</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============================= TOC ============================= -->
|
||||
<section class="toc">
|
||||
<div class="kick">Contenido</div>
|
||||
<h2>Índice</h2>
|
||||
<div class="bar"></div>
|
||||
<div class="toc-row"><span class="n">—</span><span class="t">Resumen ejecutivo</span><span class="dots"></span><span class="pg">{{PG_execsum}}</span></div>
|
||||
<div class="toc-row section"><span class="n">01</span><span class="t">Entendimiento del proyecto</span><span class="dots"></span><span class="pg">{{PG_1}}</span></div>
|
||||
<div class="toc-row section"><span class="n">02</span><span class="t">Alcance y requerimientos</span><span class="dots"></span><span class="pg">{{PG_2}}</span></div>
|
||||
<div class="toc-row section"><span class="n">03</span><span class="t">Inversión y modelo</span><span class="dots"></span><span class="pg">{{PG_3}}</span></div>
|
||||
<div class="toc-row section"><span class="n">A</span><span class="t">Anexo A — Cobertura por módulo</span><span class="dots"></span><span class="pg">{{PG_anexoA}}</span></div>
|
||||
</section>
|
||||
|
||||
<!-- ============================= BODY ============================= -->
|
||||
<main class="content">
|
||||
|
||||
<section class="sec">
|
||||
<div class="sec-head no-num"><div><span class="kick">Resumen</span><h2>Resumen ejecutivo</h2></div></div>
|
||||
<p class="drop">Esta propuesta resume el alcance, la inversión y el modelo de colaboración para construir una plataforma de reservaciones para Náutica del Norte, que hoy gestiona sus reservas por teléfono y hojas de cálculo. El objetivo de la primera etapa es centralizar la disponibilidad, el cobro y la confirmación en una sola herramienta, reduciendo el trabajo manual y los errores de doble reserva.</p>
|
||||
|
||||
<div class="synthesis">
|
||||
<div class="stat"><div class="big">6 sem</div><div class="lab">Plazo estimado</div></div>
|
||||
<div class="stat"><div class="big">$72K</div><div class="lab">Inversión (MXN)</div></div>
|
||||
<div class="stat"><div class="big">24</div><div class="lab">Requerimientos</div></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="sec">
|
||||
<div class="sec-head"><div class="sec-num">01</div><div><h2>Entendimiento del proyecto</h2></div></div>
|
||||
<p class="lede">El reto principal del proyecto es eliminar la coordinación manual de reservas sin interrumpir la operación durante temporada alta.</p>
|
||||
<p>El flujo objetivo conecta la disponibilidad de embarcaciones con el cobro y la confirmación automática al cliente:</p>
|
||||
<div class="flow">
|
||||
<span class="chip">Disponibilidad</span><span class="arrow">→</span>
|
||||
<span class="chip">Reserva</span><span class="arrow">→</span>
|
||||
<span class="chip accent">Cobro</span><span class="arrow">→</span>
|
||||
<span class="chip">Confirmación</span><span class="arrow">→</span>
|
||||
<span class="chip">Recordatorio</span>
|
||||
</div>
|
||||
<div class="callout cool"><strong>Prioridad de la primera etapa:</strong> disponibilidad, reserva y cobro en línea. La conciliación contable y el programa de lealtad se difieren a fases posteriores.</div>
|
||||
</section>
|
||||
|
||||
<section class="sec">
|
||||
<div class="sec-head"><div class="sec-num">02</div><div><h2>Alcance y requerimientos</h2></div></div>
|
||||
<p>El alcance se organiza en módulos. La siguiente matriz lista los requerimientos funcionales de la primera etapa; es deliberadamente extensa para mostrar cómo una tabla larga reparte sus filas entre páginas repitiendo el encabezado.</p>
|
||||
<table class="compact">
|
||||
<thead><tr><th style="width:46pt;">ID</th><th>Requerimiento</th><th>Módulo</th><th class="ctr" style="width:54pt;">Prioridad</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="k">RF-01</td><td>Calendario de disponibilidad por embarcación</td><td>Reservas</td><td class="ctr">Alta</td></tr>
|
||||
<tr><td class="k">RF-02</td><td>Bloqueo de horarios por mantenimiento</td><td>Reservas</td><td class="ctr">Alta</td></tr>
|
||||
<tr><td class="k">RF-03</td><td>Reserva con selección de fecha, hora y duración</td><td>Reservas</td><td class="ctr">Alta</td></tr>
|
||||
<tr><td class="k">RF-04</td><td>Reglas de anticipación mínima y máxima</td><td>Reservas</td><td class="ctr">Media</td></tr>
|
||||
<tr><td class="k">RF-05</td><td>Cupos y capacidad por embarcación</td><td>Reservas</td><td class="ctr">Alta</td></tr>
|
||||
<tr><td class="k">RF-06</td><td>Cobro en línea con tarjeta</td><td>Pagos</td><td class="ctr">Alta</td></tr>
|
||||
<tr><td class="k">RF-07</td><td>Cobro de anticipo configurable</td><td>Pagos</td><td class="ctr">Media</td></tr>
|
||||
<tr><td class="k">RF-08</td><td>Reembolsos y cancelaciones con política</td><td>Pagos</td><td class="ctr">Media</td></tr>
|
||||
<tr><td class="k">RF-09</td><td>Comprobante de pago al cliente</td><td>Pagos</td><td class="ctr">Alta</td></tr>
|
||||
<tr><td class="k">RF-10</td><td>Confirmación automática por correo</td><td>Notificaciones</td><td class="ctr">Alta</td></tr>
|
||||
<tr><td class="k">RF-11</td><td>Recordatorio previo a la reserva</td><td>Notificaciones</td><td class="ctr">Media</td></tr>
|
||||
<tr><td class="k">RF-12</td><td>Notificación de cambios o cancelación</td><td>Notificaciones</td><td class="ctr">Media</td></tr>
|
||||
<tr><td class="k">RF-13</td><td>Registro y autenticación de clientes</td><td>Clientes</td><td class="ctr">Alta</td></tr>
|
||||
<tr><td class="k">RF-14</td><td>Historial de reservas del cliente</td><td>Clientes</td><td class="ctr">Media</td></tr>
|
||||
<tr><td class="k">RF-15</td><td>Datos de contacto y preferencias</td><td>Clientes</td><td class="ctr">Baja</td></tr>
|
||||
<tr><td class="k">RF-16</td><td>Panel de operación con agenda del día</td><td>Operación</td><td class="ctr">Alta</td></tr>
|
||||
<tr><td class="k">RF-17</td><td>Reprogramación manual desde el panel</td><td>Operación</td><td class="ctr">Media</td></tr>
|
||||
<tr><td class="k">RF-18</td><td>Lista blanca de clientes frecuentes</td><td>Operación</td><td class="ctr">Baja</td></tr>
|
||||
<tr><td class="k">RF-19</td><td>Reporte de ocupación por periodo</td><td>Reportes</td><td class="ctr">Media</td></tr>
|
||||
<tr><td class="k">RF-20</td><td>Reporte de ingresos por embarcación</td><td>Reportes</td><td class="ctr">Media</td></tr>
|
||||
<tr><td class="k">RF-21</td><td>Exportación a Excel</td><td>Reportes</td><td class="ctr">Baja</td></tr>
|
||||
<tr><td class="k">RF-22</td><td>Roles y permisos (admin / operador)</td><td>Seguridad</td><td class="ctr">Alta</td></tr>
|
||||
<tr><td class="k">RF-23</td><td>Bitácora de cambios</td><td>Seguridad</td><td class="ctr">Media</td></tr>
|
||||
<tr><td class="k">RF-24</td><td>Respaldo automático de la base de datos</td><td>Seguridad</td><td class="ctr">Alta</td></tr>
|
||||
<tr class="total"><td>Total</td><td>24 requerimientos en 7 módulos</td><td colspan="2" class="num">Etapa 1</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="callout">La matriz completa de requerimientos no funcionales (rendimiento, seguridad y disponibilidad) se acuerda en el Discovery y se anexa al contrato.</div>
|
||||
</section>
|
||||
|
||||
<section class="sec">
|
||||
<div class="sec-head"><div class="sec-num">03</div><div><h2>Inversión y modelo</h2></div></div>
|
||||
<p>El proyecto se entrega bajo un esquema de tiempo y materiales con tope por etapa, a una tarifa de $600 MXN/h + IVA.</p>
|
||||
<table>
|
||||
<thead><tr><th>Etapa</th><th>Entregable</th><th class="num">Horas</th><th class="num">Inversión (MXN)</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="k">0</td><td>Discovery y arquitectura</td><td class="num">20</td><td class="num">$12,000</td></tr>
|
||||
<tr><td class="k">1</td><td>Reservas y disponibilidad</td><td class="num">36</td><td class="num">$21,600</td></tr>
|
||||
<tr><td class="k">2</td><td>Pagos y notificaciones</td><td class="num">34</td><td class="num">$20,400</td></tr>
|
||||
<tr><td class="k">3</td><td>Operación, reportes y cierre</td><td class="num">30</td><td class="num">$18,000</td></tr>
|
||||
<tr class="total"><td>Total</td><td>Primera etapa</td><td class="num">120 h</td><td class="num">$72,000</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="signoff">
|
||||
<div class="nm">Johann Velázquez</div>
|
||||
<div class="rl">Consultor de Software · Monterrey, Nuevo León</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="sec break">
|
||||
<div class="sec-head"><div class="sec-num">A</div><div><span class="kick">Anexo</span><h2>Cobertura por módulo</h2></div></div>
|
||||
<p class="lede">Este anexo detalla la cobertura de cada módulo en la primera etapa.</p>
|
||||
<table>
|
||||
<thead><tr><th>Módulo</th><th class="ctr" style="width:62pt;">Cobertura</th><th>Detalle</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="k">Reservas</td><td class="cov">✅</td><td>Disponibilidad, reserva, cupos y reglas de anticipación.</td></tr>
|
||||
<tr><td class="k">Pagos</td><td class="cov">✅</td><td>Cobro con tarjeta, anticipo y comprobante.</td></tr>
|
||||
<tr><td class="k">Conciliación contable</td><td class="cov">❌</td><td>Diferida a fase posterior.</td></tr>
|
||||
<tr><td class="k">Lealtad</td><td class="cov">❌</td><td>Diferida a fase posterior.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,459 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
build_pdf.py — print-optimized PDF builder for editorial proposals/reports.
|
||||
|
||||
Pipeline (the reliable part — don't reinvent it):
|
||||
1. Render the authored HTML to PDF with headless Chromium (Playwright),
|
||||
letting CSS @page rules control the page geometry.
|
||||
2. Resolve the table-of-contents page numbers in a SECOND pass: render once
|
||||
with the {{PG_*}} tokens blanked, find the real start page of each section
|
||||
by searching the rendered PDF for a unique body phrase, substitute the
|
||||
numbers, then re-render.
|
||||
3. Stamp a running footer (hairline + confidential line + page number) on
|
||||
every page except the cover, using a registered TTF.
|
||||
4. Write PDF metadata (title / author / subject).
|
||||
|
||||
Why two passes instead of computing pages from the DOM: with CSS paged media,
|
||||
the mapping from DOM position to printed page is not linear (page margins and
|
||||
break-before rules eat space). Searching the actually-rendered PDF sidesteps
|
||||
all of it and is rock-solid.
|
||||
|
||||
Usage:
|
||||
python3 build_pdf.py [config.json] # build
|
||||
python3 build_pdf.py [config.json] --check # validate config + env only
|
||||
|
||||
If no config path is given it looks for ./pdf.config.json.
|
||||
See pdf.config.example.json for the full schema.
|
||||
"""
|
||||
|
||||
import sys, os, re, json, io, tempfile, pathlib
|
||||
|
||||
# Windows consoles default to cp1252 and choke on the ✓/⚠/✗ status glyphs this
|
||||
# script prints. Force UTF-8 on stdout/stderr so progress output never crashes.
|
||||
for _stream in (sys.stdout, sys.stderr):
|
||||
try:
|
||||
_stream.reconfigure(encoding="utf-8")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# --- friendly dependency check -------------------------------------------- #
|
||||
_MISSING = []
|
||||
try:
|
||||
from playwright.sync_api import sync_playwright
|
||||
except Exception:
|
||||
_MISSING.append("playwright")
|
||||
try:
|
||||
import pdfplumber
|
||||
except Exception:
|
||||
_MISSING.append("pdfplumber")
|
||||
try:
|
||||
import pypdf
|
||||
except Exception:
|
||||
_MISSING.append("pypdf")
|
||||
try:
|
||||
from reportlab.pdfgen import canvas
|
||||
from reportlab.lib.units import mm
|
||||
from reportlab.lib.colors import Color
|
||||
from reportlab.pdfbase import pdfmetrics
|
||||
from reportlab.pdfbase.ttfonts import TTFont as RLTTFont
|
||||
except Exception:
|
||||
_MISSING.append("reportlab")
|
||||
|
||||
if _MISSING:
|
||||
sys.exit(
|
||||
"Missing Python packages: " + ", ".join(_MISSING) + "\n"
|
||||
"Install with:\n"
|
||||
" pip install playwright pdfplumber pypdf reportlab\n"
|
||||
" python -m playwright install chromium"
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Page geometry
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Named sizes in points (1pt = 1/72in). Used for the footer overlay canvas so
|
||||
# it matches whatever Chromium printed.
|
||||
PAGE_SIZES_PT = {
|
||||
"letter": (612.0, 792.0),
|
||||
"legal": (612.0, 1008.0),
|
||||
"a4": (595.28, 841.89),
|
||||
"a3": (841.89, 1190.55),
|
||||
}
|
||||
|
||||
|
||||
def resolve_page_size(page_size):
|
||||
"""Return (playwright_format_or_None, width_pt, height_pt, width_mm, height_mm).
|
||||
|
||||
page_size may be a string ("Letter"/"A4"/...) or a dict
|
||||
{"width_mm": .., "height_mm": ..} for a custom size.
|
||||
"""
|
||||
if isinstance(page_size, dict):
|
||||
wmm = float(page_size["width_mm"]); hmm = float(page_size["height_mm"])
|
||||
return None, wmm * 72 / 25.4, hmm * 72 / 25.4, wmm, hmm
|
||||
key = str(page_size).strip().lower()
|
||||
if key not in PAGE_SIZES_PT:
|
||||
sys.exit(f"Unknown page_size {page_size!r}. Use one of "
|
||||
f"{sorted(PAGE_SIZES_PT)} or a {{width_mm,height_mm}} object.")
|
||||
wpt, hpt = PAGE_SIZES_PT[key]
|
||||
return key.capitalize(), wpt, hpt, wpt * 25.4 / 72, hpt * 25.4 / 72
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Config
|
||||
# --------------------------------------------------------------------------- #
|
||||
def load_config(path):
|
||||
p = pathlib.Path(path)
|
||||
if not p.exists():
|
||||
sys.exit(f"Config not found: {path}\nCopy pdf.config.example.json and edit it.")
|
||||
try:
|
||||
cfg = json.loads(p.read_text(encoding="utf-8"))
|
||||
except json.JSONDecodeError as e:
|
||||
sys.exit(f"Config is not valid JSON ({path}): {e}")
|
||||
|
||||
cfg.setdefault("metadata", {})
|
||||
cfg.setdefault("toc", {})
|
||||
cfg.setdefault("footer", {})
|
||||
f = cfg["footer"]
|
||||
f.setdefault("skip_first_page", True)
|
||||
f.setdefault("skip_pages", [])
|
||||
f.setdefault("rule", True)
|
||||
f.setdefault("font_size_pt", 7.5)
|
||||
f.setdefault("margin_mm", 12) # distance from the bottom edge
|
||||
f.setdefault("color", [0.46, 0.51, 0.57])
|
||||
f.setdefault("page_number_format", "{page}")
|
||||
cfg.setdefault("fonts", {})
|
||||
cfg.setdefault("cover_full_bleed", True)
|
||||
cfg.setdefault("page_size", "Letter")
|
||||
cfg.setdefault("render_timeout_ms", 30000)
|
||||
|
||||
base = p.resolve().parent
|
||||
for key in ("input_html", "output_pdf"):
|
||||
if key in cfg and not os.path.isabs(cfg[key]):
|
||||
cfg[key] = str(base / cfg[key])
|
||||
ttf = cfg["fonts"].get("footer_ttf")
|
||||
if ttf and not os.path.isabs(ttf):
|
||||
cand = base / ttf
|
||||
# only rewrite to a config-relative path if that file actually exists;
|
||||
# otherwise leave the original (likely an absolute system font path)
|
||||
if cand.exists():
|
||||
cfg["fonts"]["footer_ttf"] = str(cand)
|
||||
return cfg
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Token / anchor helpers
|
||||
# --------------------------------------------------------------------------- #
|
||||
TOKEN_RE = re.compile(r"\{\{PG_([^}]+)\}\}")
|
||||
|
||||
|
||||
def _norm(s):
|
||||
return re.sub(r"\s+", " ", s or "")
|
||||
|
||||
|
||||
def strip_comments(html):
|
||||
# HTML comments never belong in the rendered PDF, and any {{PG_*}} examples
|
||||
# inside them must not be treated as real tokens.
|
||||
return re.sub(r"<!--.*?-->", "", html, flags=re.DOTALL)
|
||||
|
||||
|
||||
def tokens_in_html(html):
|
||||
return set(TOKEN_RE.findall(html))
|
||||
|
||||
|
||||
def blank_tokens(html):
|
||||
return TOKEN_RE.sub("", html)
|
||||
|
||||
|
||||
def fill_tokens(html, pages):
|
||||
def repl(m):
|
||||
key = m.group(1)
|
||||
if key not in pages:
|
||||
sys.exit(f"Token {{{{PG_{key}}}}} has no matching entry in config 'toc'.")
|
||||
return str(pages[key])
|
||||
return TOKEN_RE.sub(repl, html)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Preflight validation
|
||||
# --------------------------------------------------------------------------- #
|
||||
def chromium_ok():
|
||||
try:
|
||||
with sync_playwright() as p:
|
||||
b = p.chromium.launch()
|
||||
b.close()
|
||||
return True, ""
|
||||
except Exception as e:
|
||||
return False, str(e).splitlines()[0]
|
||||
|
||||
|
||||
def preflight(cfg, html):
|
||||
"""Validate config + environment. Returns list of warning strings; exits on
|
||||
hard errors."""
|
||||
problems, warnings = [], []
|
||||
|
||||
# input html
|
||||
if not os.path.exists(cfg.get("input_html", "")):
|
||||
problems.append(f"input_html not found: {cfg.get('input_html')!r}")
|
||||
|
||||
# token <-> toc consistency
|
||||
toks = tokens_in_html(html)
|
||||
toc_keys = {k for k in cfg["toc"] if not k.startswith("_")}
|
||||
missing_cfg = toks - toc_keys # token in HTML, no anchor in config
|
||||
unused_cfg = toc_keys - toks # anchor in config, no token in HTML
|
||||
if missing_cfg:
|
||||
problems.append("TOC tokens in the HTML with no anchor in config 'toc': "
|
||||
+ ", ".join(sorted("{{PG_%s}}" % k for k in missing_cfg)))
|
||||
if unused_cfg:
|
||||
warnings.append("config 'toc' keys with no matching {{PG_*}} token in the HTML: "
|
||||
+ ", ".join(sorted(unused_cfg)))
|
||||
|
||||
# footer font
|
||||
ttf = cfg["fonts"].get("footer_ttf")
|
||||
if ttf and not os.path.exists(ttf):
|
||||
warnings.append(f"footer_ttf not found ({ttf}); falling back to Helvetica.")
|
||||
|
||||
# output dir
|
||||
out = cfg.get("output_pdf")
|
||||
if out:
|
||||
os.makedirs(os.path.dirname(os.path.abspath(out)), exist_ok=True)
|
||||
|
||||
# chromium
|
||||
ok, err = chromium_ok()
|
||||
if not ok:
|
||||
problems.append("Chromium failed to launch: " + err
|
||||
+ "\n Run: python -m playwright install chromium")
|
||||
|
||||
if problems:
|
||||
sys.exit("Preflight failed:\n" + "\n".join(" ✗ " + p for p in problems))
|
||||
return warnings
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 1. Render
|
||||
# --------------------------------------------------------------------------- #
|
||||
def render(html_text, out_pdf, pw_format, width_pt, height_pt, timeout_ms, base_dir=None):
|
||||
# Write the temp HTML in the SAME directory as the source document so that
|
||||
# relative resources in the HTML — @font-face url("fonts/..."), <img src>,
|
||||
# CSS background images — resolve against the document folder. (Rendering
|
||||
# from the system temp dir would break every relative path.)
|
||||
with tempfile.NamedTemporaryFile("w", prefix=".pdfbuild_", suffix=".html",
|
||||
delete=False, encoding="utf-8", dir=base_dir) as fh:
|
||||
fh.write(html_text)
|
||||
tmp_path = fh.name
|
||||
try:
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch()
|
||||
page = browser.new_page()
|
||||
page.set_default_timeout(timeout_ms)
|
||||
uri = pathlib.Path(tmp_path).resolve().as_uri()
|
||||
# networkidle is ideal but can hang on a stuck resource; fall back to
|
||||
# 'load' so a self-contained document always renders.
|
||||
try:
|
||||
page.goto(uri, wait_until="networkidle", timeout=timeout_ms)
|
||||
except Exception:
|
||||
page.goto(uri, wait_until="load", timeout=timeout_ms)
|
||||
page.emulate_media(media="print")
|
||||
pdf_kwargs = dict(
|
||||
path=out_pdf,
|
||||
print_background=True,
|
||||
# margin 0 here so the CSS @page rules are the single source of
|
||||
# truth — including @page:first{margin:0} for a full-bleed cover.
|
||||
margin={"top": "0", "right": "0", "bottom": "0", "left": "0"},
|
||||
prefer_css_page_size=True,
|
||||
)
|
||||
if pw_format:
|
||||
pdf_kwargs["format"] = pw_format
|
||||
else: # custom size
|
||||
pdf_kwargs["width"] = f"{width_pt}pt"
|
||||
pdf_kwargs["height"] = f"{height_pt}pt"
|
||||
page.pdf(**pdf_kwargs)
|
||||
browser.close()
|
||||
finally:
|
||||
os.unlink(tmp_path)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 2. TOC page-number resolution
|
||||
# --------------------------------------------------------------------------- #
|
||||
def detect_pages(pdf_path, anchors):
|
||||
"""Map each TOC key to the 1-based page where its anchor phrase first appears."""
|
||||
with pdfplumber.open(pdf_path) as pdf:
|
||||
page_texts = [_norm(pg.extract_text() or "") for pg in pdf.pages]
|
||||
pages, missing, ambiguous = {}, [], []
|
||||
for key, phrase in anchors.items():
|
||||
if key.startswith("_"):
|
||||
continue
|
||||
target = _norm(phrase)
|
||||
hits = [i + 1 for i, txt in enumerate(page_texts) if target in txt]
|
||||
if not hits:
|
||||
missing.append((key, phrase))
|
||||
else:
|
||||
pages[key] = hits[0]
|
||||
if len(hits) > 1:
|
||||
ambiguous.append((key, hits))
|
||||
if missing:
|
||||
lines = "\n".join(f" - {k!r}: {p!r}" for k, p in missing)
|
||||
sys.exit(
|
||||
"Could not locate these TOC anchors in the rendered PDF.\n"
|
||||
"Use a UNIQUE phrase from the section BODY (not its title), and avoid\n"
|
||||
"the first letter of a drop-cap paragraph:\n" + lines
|
||||
)
|
||||
for k, hits in ambiguous:
|
||||
print(f" ! anchor {k!r} appears on pages {hits}; using the first ({hits[0]}). "
|
||||
"Use a more specific phrase if that's wrong.")
|
||||
return pages
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 3. Footer stamping + 4. Metadata
|
||||
# --------------------------------------------------------------------------- #
|
||||
def register_footer_font(ttf_path):
|
||||
if ttf_path and os.path.exists(ttf_path):
|
||||
try:
|
||||
pdfmetrics.registerFont(RLTTFont("FooterFont", ttf_path))
|
||||
return "FooterFont"
|
||||
except Exception:
|
||||
pass
|
||||
return "Helvetica"
|
||||
|
||||
|
||||
def make_overlay(page_number, total_pages, fcfg, font_name, width_pt, height_pt):
|
||||
buf = io.BytesIO()
|
||||
c = canvas.Canvas(buf, pagesize=(width_pt, height_pt))
|
||||
left = 16 * mm
|
||||
right_x = width_pt - 16 * mm
|
||||
y_text = fcfg["margin_mm"] * mm
|
||||
y_rule = y_text + 3.2 * mm
|
||||
col = Color(*fcfg["color"])
|
||||
if fcfg.get("rule", True):
|
||||
c.setStrokeColor(Color(0.80, 0.83, 0.86))
|
||||
c.setLineWidth(0.5)
|
||||
c.line(left, y_rule, right_x, y_rule)
|
||||
c.setFillColor(col)
|
||||
c.setFont(font_name, float(fcfg["font_size_pt"]))
|
||||
text = fcfg.get("text", "")
|
||||
if text:
|
||||
c.drawString(left, y_text, text)
|
||||
num = fcfg.get("page_number_format", "{page}").format(page=page_number, pages=total_pages)
|
||||
if num:
|
||||
c.drawRightString(right_x, y_text, num)
|
||||
c.showPage()
|
||||
c.save()
|
||||
buf.seek(0)
|
||||
return pypdf.PdfReader(buf).pages[0]
|
||||
|
||||
|
||||
def stamp_and_finalize(src_pdf, out_pdf, fcfg, font_name, metadata, width_pt, height_pt):
|
||||
reader = pypdf.PdfReader(src_pdf)
|
||||
writer = pypdf.PdfWriter()
|
||||
total = len(reader.pages)
|
||||
skip_first = fcfg.get("skip_first_page", True)
|
||||
skip_pages = set(fcfg.get("skip_pages", []))
|
||||
for idx, page in enumerate(reader.pages):
|
||||
page_no = idx + 1
|
||||
skip = (page_no in skip_pages) or (idx == 0 and skip_first)
|
||||
if not skip:
|
||||
page.merge_page(make_overlay(page_no, total, fcfg, font_name, width_pt, height_pt))
|
||||
writer.add_page(page)
|
||||
meta = {}
|
||||
if metadata.get("title"): meta["/Title"] = metadata["title"]
|
||||
if metadata.get("author"): meta["/Author"] = metadata["author"]
|
||||
if metadata.get("subject"): meta["/Subject"] = metadata["subject"]
|
||||
if metadata.get("author"): meta["/Creator"] = metadata["author"]
|
||||
if metadata.get("keywords"): meta["/Keywords"] = metadata["keywords"]
|
||||
if meta:
|
||||
writer.add_metadata(meta)
|
||||
with open(out_pdf, "wb") as fh:
|
||||
writer.write(fh)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Post-build: warn if the design fonts didn't make it into the PDF
|
||||
# --------------------------------------------------------------------------- #
|
||||
# The design system is built on Caladea (serif display) + Carlito (sans body).
|
||||
# If those aren't embedded, Chromium silently fell back to system fonts
|
||||
# (Cambria/Calibri/Georgia/…) — it *looks close* but isn't the intended result,
|
||||
# exactly the kind of regression that ships unnoticed. Flag it loudly.
|
||||
DESIGN_FONTS = ("Caladea", "Carlito")
|
||||
|
||||
|
||||
def report_embedded_fonts(pdf_path):
|
||||
try:
|
||||
reader = pypdf.PdfReader(pdf_path)
|
||||
names = set()
|
||||
for pg in reader.pages:
|
||||
res = pg.get("/Resources")
|
||||
fonts = res.get("/Font") if res else None
|
||||
if not fonts:
|
||||
continue
|
||||
fobj = fonts.get_object()
|
||||
for fk in fobj:
|
||||
bf = fobj[fk].get_object().get("/BaseFont")
|
||||
if bf:
|
||||
names.add(str(bf).lstrip("/").split("+")[-1]) # drop subset prefix
|
||||
except Exception:
|
||||
return # a reporting step must never fail the build
|
||||
present = sorted(names)
|
||||
print(" fonts embedded:", ", ".join(present) if present else "(none)")
|
||||
missing = [f for f in DESIGN_FONTS if not any(f in n for n in names)]
|
||||
if missing:
|
||||
print(" ⚠ design font(s) missing from the PDF: " + ", ".join(missing) + ".")
|
||||
print(" Chromium fell back to system fonts, so the result looks")
|
||||
print(" 'close but not identical' to the intended design. Ensure the")
|
||||
print(" .ttf files sit next to the HTML (./fonts/) and the @font-face")
|
||||
print(" url() paths resolve. See SKILL.md › Fonts.")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Orchestration
|
||||
# --------------------------------------------------------------------------- #
|
||||
def main():
|
||||
args = [a for a in sys.argv[1:]]
|
||||
check_only = "--check" in args
|
||||
args = [a for a in args if a != "--check"]
|
||||
cfg_path = args[0] if args else "pdf.config.json"
|
||||
|
||||
cfg = load_config(cfg_path)
|
||||
raw_html = pathlib.Path(cfg["input_html"]).read_text(encoding="utf-8") \
|
||||
if os.path.exists(cfg["input_html"]) else ""
|
||||
src_html = strip_comments(raw_html)
|
||||
|
||||
warnings = preflight(cfg, src_html)
|
||||
for w in warnings:
|
||||
print(" ⚠ " + w)
|
||||
if check_only:
|
||||
print("Preflight OK." + (" (with warnings)" if warnings else ""))
|
||||
return
|
||||
|
||||
pw_format, width_pt, height_pt, _, _ = resolve_page_size(cfg["page_size"])
|
||||
timeout_ms = int(cfg["render_timeout_ms"])
|
||||
# render relative resources (fonts/images) against the document's folder
|
||||
base_dir = os.path.dirname(os.path.abspath(cfg["input_html"])) or None
|
||||
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
v1 = os.path.join(td, "v1.pdf")
|
||||
v2 = os.path.join(td, "v2.pdf")
|
||||
|
||||
if any(not k.startswith("_") for k in cfg["toc"]):
|
||||
print("Pass 1/2: resolving TOC page numbers…")
|
||||
render(blank_tokens(src_html), v1, pw_format, width_pt, height_pt, timeout_ms, base_dir)
|
||||
pages = detect_pages(v1, cfg["toc"])
|
||||
print(" resolved:", pages)
|
||||
html_final = fill_tokens(src_html, pages)
|
||||
else:
|
||||
html_final = blank_tokens(src_html)
|
||||
|
||||
print("Pass 2/2: rendering final document…")
|
||||
render(html_final, v2, pw_format, width_pt, height_pt, timeout_ms, base_dir)
|
||||
|
||||
font_name = register_footer_font(cfg["fonts"].get("footer_ttf"))
|
||||
stamp_and_finalize(v2, cfg["output_pdf"], cfg["footer"], font_name,
|
||||
cfg["metadata"], width_pt, height_pt)
|
||||
|
||||
n = len(pypdf.PdfReader(cfg["output_pdf"]).pages)
|
||||
print(f"✓ Wrote {cfg['output_pdf']} ({n} pages, {cfg['page_size']})")
|
||||
report_embedded_fonts(cfg["output_pdf"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,55 @@
|
||||
#!/usr/bin/env python3
|
||||
"""render_check.py — rasterize PDF pages to PNG for visual verification.
|
||||
|
||||
Cross-platform: uses pypdfium2 (already pulled in with pdfplumber), so it needs
|
||||
no poppler/pdftoppm. On Windows this is the practical way to do the "ALWAYS
|
||||
verify visually" step from SKILL.md.
|
||||
|
||||
Usage:
|
||||
python render_check.py Output.pdf # every page -> Output_p1.png, ...
|
||||
python render_check.py Output.pdf 1 2 5 # only pages 1, 2, 5 (1-based)
|
||||
python render_check.py Output.pdf 1 2 --scale 2 # higher resolution
|
||||
"""
|
||||
import sys, pathlib
|
||||
|
||||
try:
|
||||
import pypdfium2 as pdfium
|
||||
except Exception:
|
||||
sys.exit("Missing pypdfium2 (it installs alongside pdfplumber).\n"
|
||||
" pip install pypdfium2")
|
||||
|
||||
|
||||
def main():
|
||||
args = list(sys.argv[1:])
|
||||
if not args:
|
||||
sys.exit("Usage: python render_check.py <pdf> [pages...] [--scale N]")
|
||||
|
||||
scale = 1.5
|
||||
if "--scale" in args:
|
||||
i = args.index("--scale")
|
||||
try:
|
||||
scale = float(args[i + 1])
|
||||
except (IndexError, ValueError):
|
||||
sys.exit("--scale needs a number, e.g. --scale 2")
|
||||
del args[i:i + 2]
|
||||
|
||||
pdf_path = args[0]
|
||||
if not pathlib.Path(pdf_path).exists():
|
||||
sys.exit(f"PDF not found: {pdf_path}")
|
||||
pages = [int(a) for a in args[1:]]
|
||||
|
||||
pdf = pdfium.PdfDocument(pdf_path)
|
||||
n = len(pdf)
|
||||
idxs = [p - 1 for p in pages] if pages else range(n)
|
||||
stem = pathlib.Path(pdf_path).with_suffix("")
|
||||
for i in idxs:
|
||||
if i < 0 or i >= n:
|
||||
print(f" ! page {i + 1} out of range (1..{n})")
|
||||
continue
|
||||
out = f"{stem}_p{i + 1}.png"
|
||||
pdf[i].render(scale=scale).to_pil().save(out)
|
||||
print("wrote", out)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user