diff --git a/README.md b/README.md
index f0b4563..423bdca 100644
--- a/README.md
+++ b/README.md
@@ -114,6 +114,14 @@ actually use to draft notes and docs.
Headings, code blocks, quotes and tables are themed, with separate export
settings (color scheme, fonts, page size and orientation) and careful
pagination (no orphan headings, `\newpage` support).
+- **Page templates** (Custom, Article, Manuscript, Letter, Compact) group
+ margins, body and code typography, line spacing, heading scale, header and
+ footer and an optional title block. The same template feeds both the exported
+ file and the preview, so what you see is what prints.
+- **Print preview** (menu, next to Export to PDF) renders the real pages through
+ the export pipeline before committing to paper or a file.
+- **Your own stylesheet**: an optional CSS file applies on top of the chosen
+ template (see [Stylesheets](#stylesheets)).
- **Files**: open, save and save-as with `GtkFileDialog`, atomic writes,
unsaved-changes warning, configurable autosave.
- **Sidebar** (F9) with filterable recent files and a navigable outline.
@@ -140,6 +148,39 @@ Markers replaced by an ornament (bullets, `[x]`, `---`, `>`, fences, pipes) are
shrunk rather than hidden: the tiny glyph stays in GTK's layout, so the
invisible-text abort path of GNOME/gtk#8346 is unreachable by construction.
+## Stylesheets
+
+`Preferences > Export > Stylesheet` loads a CSS file that is applied on top of
+the page template. It is a mapped subset, not a CSS engine: the export paints
+and paginates with cairo, so each declaration is translated into a value the
+layout already understands, and anything outside this table is ignored (the
+export warns once and keeps going).
+
+| Selector | Properties |
+| --- | --- |
+| `@page` | `margin`, `margin-top`, `margin-right`, `margin-bottom`, `margin-left` (`pt`, `mm`, `cm`, `in`, `px`) |
+| `body`, `html`, `:root` | `font-family`, `font-size`, `line-height`, `color`, `background-color`, `margin-bottom`, `--block-spacing` |
+| `p` | `line-height`, `color`, `margin-bottom`, `--block-spacing` |
+| `h1` to `h4` | `font-size`, `color` |
+| `pre`, `code` | `font-family`, `color`, `background-color` |
+| `blockquote`, `a`, `table` | `color`, plus `background-color` on `table` |
+
+When the sheet says nothing about a value, the template keeps its own, and
+removing the file restores the previous output exactly. Example:
+
+```css
+@page { margin: 2cm 2.5cm }
+body {
+ font-family: "Source Serif 4", serif;
+ font-size: 11pt;
+ line-height: 1.6;
+ color: #1a1a1a;
+ --block-spacing: 8pt;
+}
+h1 { font-size: 20pt; color: #003366 }
+pre { background-color: #f5f5f5 }
+```
+
## Limitations
- **UTF-8 only.** Files are read as UTF-8 and written back byte for byte. A
diff --git a/data/io.github.gnacho.Scribe.gschema.xml b/data/io.github.gnacho.Scribe.gschema.xml
index 0184c2d..95b0916 100644
--- a/data/io.github.gnacho.Scribe.gschema.xml
+++ b/data/io.github.gnacho.Scribe.gschema.xml
@@ -34,6 +34,13 @@
+
+
+
+
+
+
+
@@ -152,5 +159,13 @@
'portrait'
PDF export page orientation
+
+ 'custom'
+ PDF export page template
+
+
+ ''
+ Path of the user stylesheet applied on top of the page template
+
diff --git a/po/es.po b/po/es.po
index f7582ad..4dd4a68 100644
--- a/po/es.po
+++ b/po/es.po
@@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: scribe\n"
"Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2026-10-19 14:53+0200\n"
+"POT-Creation-Date: 2026-10-27 06:01+0100\n"
"PO-Revision-Date: 2026-09-19 19:58+0200\n"
"Last-Translator: Automatically generated\n"
"Language-Team: none\n"
@@ -17,559 +17,598 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
-#: src/main.rs:238
+#: src/main.rs:277
msgid "Recover unsaved documents?"
msgstr "¿Recuperar los documentos sin guardar?"
-#: src/main.rs:241
+#: src/main.rs:280
msgid "_Recover All"
msgstr "_Recuperar todo"
-#: src/main.rs:242
+#: src/main.rs:281
msgid "_Discard All"
msgstr "_Descartar todo"
-#: src/main.rs:243
+#: src/main.rs:282
msgid "Decide Later"
msgstr "Decidir más tarde"
-#: src/window.rs:261 src/window.rs:701 src/window.rs:1151 src/window.rs:1423
+#: src/window.rs:290 src/window.rs:771 src/window.rs:1251 src/window.rs:1523
msgid "Untitled"
msgstr "Sin título"
-#: src/window.rs:304
+#: src/window.rs:334
msgid "Draft"
msgstr "Borrador"
-#: src/window.rs:308
+#: src/window.rs:338
msgid "Unsaved draft"
msgstr "Borrador sin guardar"
-#: src/window.rs:398 src/window.rs:2210
+#: src/window.rs:428 src/window.rs:2316
#, rust-format
msgid "Save changes to “{name}”?"
msgstr "¿Guardar los cambios en «{name}»?"
-#: src/window.rs:400 src/window.rs:2212
+#: src/window.rs:430 src/window.rs:2318
msgid "If you close without saving, you will lose what you have written."
msgstr "Si cierras sin guardar, perderás lo que hayas escrito."
-#: src/window.rs:403 src/window.rs:980 src/window.rs:2215
+#: src/window.rs:433 src/window.rs:1052 src/window.rs:2321
msgid "_Cancel"
msgstr "_Cancelar"
-#: src/window.rs:404 src/window.rs:2216
+#: src/window.rs:434 src/window.rs:2322
msgid "_Discard"
msgstr "_Descartar"
-#: src/window.rs:405 src/window.rs:747 src/window.rs:2217
+#: src/window.rs:435 src/window.rs:818 src/window.rs:2323
msgid "_Save"
msgstr "_Guardar"
-#: src/window.rs:434 src/window.rs:468 src/window.rs:2247 src/window.rs:2275
+#: src/window.rs:464 src/window.rs:498 src/window.rs:2353 src/window.rs:2381
#, rust-format
msgid "Could not save: {e}"
msgstr "No se pudo guardar: {e}"
-#: src/window.rs:516
+#: src/window.rs:546
msgid "Documents"
msgstr "Documentos"
-#: src/window.rs:522
+#: src/window.rs:552
msgid "Filter recents…"
msgstr "Filtrar recientes…"
-#: src/window.rs:551
+#: src/window.rs:581
msgid "Recents"
msgstr "Recientes"
-#: src/window.rs:553
+#: src/window.rs:583
msgid "Contents"
msgstr "Contenido"
-#: src/window.rs:596
+#: src/window.rs:641
msgid "Search"
msgstr "Buscar"
-#: src/window.rs:603
+#: src/window.rs:648
msgid "Match Case"
msgstr "Distinguir mayúsculas y minúsculas"
-#: src/window.rs:607
+#: src/window.rs:652
msgid "Regular Expression"
msgstr "Expresión regular"
-#: src/window.rs:614 src/shortcuts.ui:114
+#: src/window.rs:659 src/shortcuts.ui:114
msgid "Find Previous"
msgstr "Buscar anterior"
-#: src/window.rs:619 src/shortcuts.ui:108
+#: src/window.rs:664 src/shortcuts.ui:108
msgid "Find Next"
msgstr "Buscar siguiente"
-#: src/window.rs:627
+#: src/window.rs:671
msgid "Search & Replace"
msgstr "Buscar y reemplazar"
-#: src/window.rs:633 src/shortcuts.ui:102
+#: src/window.rs:685 src/shortcuts.ui:102
msgid "Replace"
msgstr "Reemplazar"
-#: src/window.rs:637
+#: src/window.rs:689
msgid "_Replace"
msgstr "_Reemplazar"
-#: src/window.rs:641
+#: src/window.rs:693
msgid "Replace _All"
msgstr "Reemplazar _todo"
-#: src/window.rs:693
+#: src/window.rs:748 src/window.rs:1127
+msgid "No recent documents"
+msgstr "Sin documentos recientes"
+
+#: src/window.rs:763
msgid "_Open"
msgstr "_Abrir"
-#: src/window.rs:695
+#: src/window.rs:765
msgid "Open a document (Ctrl+O)"
msgstr "Abrir un documento (Ctrl+O)"
-#: src/window.rs:709
+#: src/window.rs:779
msgid "New Tab (Ctrl+N)"
msgstr "Nueva pestaña (Ctrl+N)"
-#: src/window.rs:717
+#: src/window.rs:787
msgid "Main menu"
msgstr "Menú principal"
-#: src/window.rs:725
+#: src/window.rs:795
msgid "Find in the document (Ctrl+F)"
msgstr "Buscar en el documento (Ctrl+F)"
-#: src/window.rs:738 src/shortcuts.ui:166
-msgid "Split View"
-msgstr "Vista dividida"
+#: src/window.rs:809
+msgid "Typewriter Mode (Ctrl+Shift+T)"
+msgstr "Modo máquina de escribir (Ctrl+Mayús+T)"
-#: src/window.rs:748
+#: src/window.rs:819
msgid "Save _As…"
msgstr "Guardar _como…"
-#: src/window.rs:749
+#: src/window.rs:820
msgid "Export to _PDF…"
msgstr "Exportar a _PDF…"
-#: src/window.rs:753 src/shortcuts.ui:160
+#: src/window.rs:821
+msgid "Print Pre_view…"
+msgstr "Vista pre_via de impresión…"
+
+#: src/window.rs:825 src/shortcuts.ui:160
msgid "Sidebar"
msgstr "Barra lateral"
-#: src/window.rs:754 src/shortcuts.ui:172
+#: src/window.rs:826 src/shortcuts.ui:172
msgid "Focus Mode"
msgstr "Modo foco"
-#: src/window.rs:756 src/shortcuts.ui:178
+#: src/window.rs:828 src/shortcuts.ui:178
msgid "Typewriter Mode"
msgstr "Máquina de escribir"
-#: src/window.rs:762
+#: src/window.rs:834
msgid "_Preferences"
msgstr "_Preferencias"
-#: src/window.rs:764
+#: src/window.rs:836
msgid "_Keyboard Shortcuts"
msgstr "_Atajos de teclado"
-#: src/window.rs:767
+#: src/window.rs:839
msgid "_About Scribe"
msgstr "_Acerca de Scribe"
-#: src/window.rs:841
+#: src/window.rs:913
#, rust-format
msgid "{0} of {1}"
msgstr "{0} de {1}"
-#: src/window.rs:916 src/window.rs:960 src/window.rs:997
+#: src/window.rs:988 src/window.rs:1032 src/window.rs:1069
#, rust-format
msgid "Could not reload: {e}"
msgstr "No se pudo recargar: {e}"
-#: src/window.rs:923 src/window.rs:953
+#: src/window.rs:995 src/window.rs:1025
msgid "File deleted on disk; save to recreate it"
msgstr "El fichero se borró en disco; guárdalo para recrearlo"
-#: src/window.rs:945
+#: src/window.rs:1017
msgid "This document is not linked to a file"
msgstr "Este documento no está asociado a un fichero"
-#: src/window.rs:975
+#: src/window.rs:1047
#, rust-format
msgid "Reload “{name}” and discard changes?"
msgstr "¿Recargar «{name}» y descartar los cambios?"
-#: src/window.rs:977
+#: src/window.rs:1049
msgid "The file changed on disk. Reloading discards your unsaved edits."
msgstr "El fichero cambió en disco. Recargar descarta tus cambios sin guardar."
-#: src/window.rs:981
+#: src/window.rs:1053
msgid "_Reload"
msgstr "_Recargar"
-#: src/window.rs:1033
+#: src/window.rs:1108
msgid "Not found"
msgstr "No encontrado"
-#: src/window.rs:1051
-msgid "No recent documents"
-msgstr "Sin documentos recientes"
-
-#: src/window.rs:1053
+#: src/window.rs:1129
msgid "No results"
msgstr "Ningún resultado"
-#: src/window.rs:1333
+#: src/window.rs:1433
#, rust-format
msgid "Could not open: {e}"
msgstr "No se pudo abrir: {e}"
-#: src/window.rs:1601
+#: src/window.rs:1703
msgid "Code fences closed"
msgstr "Vallas de código cerradas"
-#: src/window.rs:1603
+#: src/window.rs:1705
msgid "No unclosed fences"
msgstr "No hay vallas abiertas"
-#: src/window.rs:1818
+#: src/window.rs:1927
msgid "That file is already open in another tab"
msgstr "Ese fichero ya está abierto en otra pestaña"
-#: src/window.rs:1971
+#: src/window.rs:2061
#, rust-format
msgid "{n} occurrences replaced"
msgstr "{n} coincidencias reemplazadas"
-#: src/window.rs:2366
+#: src/window.rs:2472
#, rust-format
msgid "Could not autosave: {e}"
msgstr "No se pudo autoguardar: {e}"
-#: src/editor.rs:1336 src/editor.rs:1341 src/editor.rs:1346
+#: src/editor.rs:1356 src/editor.rs:1361 src/editor.rs:1366
msgid "Could not save the image"
msgstr "No se pudo guardar la imagen"
-#: src/editor.rs:1386
+#: src/editor.rs:1406
msgid "Save the document before pasting images"
msgstr "Guarda el documento antes de pegar imágenes"
-#: src/editor.rs:1406 src/editor.rs:1423 src/editor.rs:1462
+#: src/editor.rs:1426 src/editor.rs:1443 src/editor.rs:1482
msgid "Save the document before dropping images"
msgstr "Guarda el documento antes de soltar imágenes"
-#: src/editor.rs:1985
+#: src/editor.rs:1986
msgid "Ignore word"
msgstr "Ignorar palabra"
-#: src/editor.rs:1992
+#: src/editor.rs:1993
msgid "Add to dictionary"
msgstr "Aniadir al diccionario"
-#: src/editor.rs:2019
+#: src/editor.rs:2020
#, rust-format
msgid "Ctrl+click to open {url}"
msgstr "Ctrl+clic para abrir {url}"
-#: src/preferences.rs:50
+#: src/preferences.rs:51
msgid "Appearance"
msgstr "Apariencia"
-#: src/preferences.rs:55
+#: src/preferences.rs:56
msgid "Theme"
msgstr "Tema"
-#: src/preferences.rs:58 src/preferences.rs:403
+#: src/preferences.rs:59 src/preferences.rs:437
msgid "Color scheme"
msgstr "Esquema de color"
-#: src/preferences.rs:59
+#: src/preferences.rs:60
msgid "Follows the system unless you force one"
msgstr "Sigue al sistema salvo que fuerces uno"
-#: src/preferences.rs:60 src/preferences.rs:406
+#: src/preferences.rs:61 src/preferences.rs:440
msgid "System"
msgstr "Sistema"
-#: src/preferences.rs:60 src/preferences.rs:80 src/preferences.rs:407
+#: src/preferences.rs:61 src/preferences.rs:441
msgid "Light"
msgstr "Claro"
-#: src/preferences.rs:60 src/preferences.rs:81 src/preferences.rs:408
+#: src/preferences.rs:61 src/preferences.rs:442
msgid "Dark"
msgstr "Oscuro"
-#: src/preferences.rs:76
-#, fuzzy
-#| msgid "Theme"
-msgid "Text theme"
-msgstr "Tema"
-
-#: src/preferences.rs:77
-msgid "Semantic colors of the document, applied live"
-msgstr "Colores semánticos del documento, aplicados en vivo"
-
-#: src/preferences.rs:79
+#: src/preferences.rs:69
msgid "Automatic (follows the color scheme)"
msgstr "Automático (sigue el esquema de color)"
-#: src/preferences.rs:82
-msgid "Sepia"
-msgstr ""
-
-#: src/preferences.rs:83
-msgid "Solarized Dark"
-msgstr "Solarized oscuro"
+#: src/preferences.rs:88
+msgid "Text theme"
+msgstr "Tema del texto"
-#: src/preferences.rs:84
-msgid "Gruvbox Dark"
-msgstr "Gruvbox oscuro"
+#: src/preferences.rs:89
+msgid "Semantic colors of the document, applied live"
+msgstr "Colores semánticos del documento, aplicados en vivo"
-#: src/preferences.rs:101
+#: src/preferences.rs:109
msgid "Text"
msgstr "Texto"
-#: src/preferences.rs:102
+#: src/preferences.rs:110
msgid "Code and tables always use a monospaced font"
msgstr "El código y las tablas usan siempre monoespaciada"
-#: src/preferences.rs:106 src/preferences.rs:414
+#: src/preferences.rs:114 src/preferences.rs:448
msgid "Font family"
msgstr "Familia tipográfica"
-#: src/preferences.rs:107 src/preferences.rs:415
+#: src/preferences.rs:115 src/preferences.rs:449
msgid "Document body"
msgstr "Cuerpo del documento"
-#: src/preferences.rs:109 src/preferences.rs:417
+#: src/preferences.rs:117 src/preferences.rs:451
msgid "Sans (Cantarell)"
msgstr "Sans (Cantarell)"
-#: src/preferences.rs:110 src/preferences.rs:418
+#: src/preferences.rs:118 src/preferences.rs:452
msgid "Serif"
msgstr "Serif"
-#: src/preferences.rs:111 src/preferences.rs:419
+#: src/preferences.rs:119 src/preferences.rs:453
msgid "Monospace"
msgstr "Monoespaciada"
-#: src/preferences.rs:116
+#: src/preferences.rs:124
msgid "Size"
msgstr "Tamaño"
-#: src/preferences.rs:117
+#: src/preferences.rs:125
msgid "Pixels"
msgstr "Píxeles"
-#: src/preferences.rs:122
+#: src/preferences.rs:130
msgid "Line spacing"
msgstr "Interlineado"
-#: src/preferences.rs:123
+#: src/preferences.rs:131
msgid "Multiplier on the font size"
msgstr "Multiplicador sobre el tamaño de fuente"
-#: src/preferences.rs:128
+#: src/preferences.rs:136
msgid "Column width"
msgstr "Ancho de la columna"
-#: src/preferences.rs:129
+#: src/preferences.rs:137
msgid "Maximum text width, in pixels"
msgstr "Ancho máximo del texto, en píxeles"
-#: src/preferences.rs:192
+#: src/preferences.rs:200
msgid "Editor"
msgstr "Editor"
-#: src/preferences.rs:197
+#: src/preferences.rs:205
msgid "Markup"
msgstr "Marcado"
-#: src/preferences.rs:199
+#: src/preferences.rs:207
msgid "What to do with asterisks, hash marks and URLs while you write"
msgstr "Qué hacer con los asteriscos, almohadillas y URLs mientras escribes"
-#: src/preferences.rs:209 src/preferences.rs:220
+#: src/preferences.rs:217 src/preferences.rs:228
msgid "Markdown marks"
msgstr "Marcas de Markdown"
-#: src/preferences.rs:210
+#: src/preferences.rs:218
msgid "“On focus” reveals them only on the cursor line"
msgstr "«Al enfocar» las revela solo en la línea del cursor"
-#: src/preferences.rs:212
+#: src/preferences.rs:220
msgid "Always hide"
msgstr "Ocultar siempre"
-#: src/preferences.rs:213
+#: src/preferences.rs:221
msgid "Show on focus"
msgstr "Mostrar al enfocar"
-#: src/preferences.rs:214 src/preferences.rs:225
+#: src/preferences.rs:222 src/preferences.rs:233
msgid "Always dim"
msgstr "Atenuar siempre"
-#: src/preferences.rs:221
+#: src/preferences.rs:229
msgid "Hiding text still crashes this GTK: the marks shrink instead"
-msgstr ""
-"Ocultar texto aún hace abortar este GTK: las marcas se encogen en su "
-"lugarOcultar texto aún aborta con este GTK: las marcas se encogen en su lugar"
+msgstr "Ocultar texto aún aborta con este GTK: las marcas se encogen en su lugar"
-#: src/preferences.rs:223
+#: src/preferences.rs:231
msgid "Always hide (shrinks the marks; GTK cannot hide them yet)"
msgstr "Ocultar siempre (encoge las marcas; GTK aún no permite ocultarlas)"
-#: src/preferences.rs:224
+#: src/preferences.rs:232
msgid "Show on focus (shrinks the marks; GTK cannot hide them yet)"
msgstr "Mostrar al enfocar (encoge las marcas; GTK aún no permite ocultarlas)"
-#: src/preferences.rs:234
+#: src/preferences.rs:242
msgid "Writing"
msgstr "Escritura"
-#: src/preferences.rs:237
+#: src/preferences.rs:245
msgid "Continue lists"
msgstr "Continuar listas"
-#: src/preferences.rs:239
+#: src/preferences.rs:247
msgid "Enter repeats the dash or number; on an empty item, it closes the list"
msgstr ""
-"Enter repite el guion o el número; en un elemento vacío, cierra la "
-"listaIntro repite el guion o el número; en un elemento vacío, cierra la lista"
+"Intro repite el guion o el número; en un elemento vacío, cierra la lista"
-#: src/preferences.rs:244
+#: src/preferences.rs:252
msgid "Focus mode"
msgstr "Modo foco"
-#: src/preferences.rs:245
+#: src/preferences.rs:253
msgid "Dims everything except the current paragraph"
msgstr "Atenúa todo salvo el párrafo actual"
-#: src/preferences.rs:249
+#: src/preferences.rs:257
msgid "Typewriter mode"
msgstr "Máquina de escribir"
-#: src/preferences.rs:250
+#: src/preferences.rs:258
msgid "Keeps the cursor line vertically centered"
msgstr "Mantiene la línea del cursor centrada verticalmente"
-#: src/preferences.rs:254
+#: src/preferences.rs:262
msgid "Spell checking"
msgstr "Corrector ortográfico"
-#: src/preferences.rs:256
+#: src/preferences.rs:264
msgid "Underlines misspelt words; right-click offers fixes"
msgstr "Subraya las palabras mal escritas; el clic derecho ofrece correcciones"
-#: src/preferences.rs:261
+#: src/preferences.rs:269
msgid "Tab width"
msgstr "Ancho de tabulación"
-#: src/preferences.rs:262
+#: src/preferences.rs:270
msgid "Spaces per indent level"
msgstr "Espacios por nivel de sangría"
-#: src/preferences.rs:273
+#: src/preferences.rs:281
msgid "Saving"
msgstr "Guardado"
-#: src/preferences.rs:276
+#: src/preferences.rs:284
msgid "Autosave"
msgstr "Guardado automático"
-#: src/preferences.rs:277
+#: src/preferences.rs:285
msgid "Only when the document already has a file"
msgstr "Solo si el documento ya tiene un fichero asociado"
-#: src/preferences.rs:281
+#: src/preferences.rs:289
msgid "Interval"
msgstr "Intervalo"
-#: src/preferences.rs:282
+#: src/preferences.rs:290
msgid "Seconds between saves"
msgstr "Segundos entre guardados"
-#: src/preferences.rs:299
+#: src/preferences.rs:307
msgid "History"
msgstr "Historial"
-#: src/preferences.rs:302
+#: src/preferences.rs:310
msgid "Clear"
msgstr "Vaciar"
-#: src/preferences.rs:307
+#: src/preferences.rs:315
msgid "Recent documents"
msgstr "Documentos recientes"
-#: src/preferences.rs:308
+#: src/preferences.rs:316
msgid "Clears the list shown in the sidebar"
msgstr "Borra la lista que aparece en la barra lateral"
-#: src/preferences.rs:391
+#: src/preferences.rs:401
+msgid "None"
+msgstr "Ninguna"
+
+#: src/preferences.rs:413
msgid "Export"
msgstr "Exportar"
-#: src/preferences.rs:396
+#: src/preferences.rs:418
msgid "PDF"
msgstr "PDF"
-#: src/preferences.rs:398
+#: src/preferences.rs:420
msgid "These settings apply to PDF export only, not to the editor view"
msgstr ""
"Estos ajustes se aplican solo a la exportación a PDF, no a la vista del "
"editor"
-#: src/preferences.rs:404
+#: src/preferences.rs:425
+msgid "Template"
+msgstr "Plantilla"
+
+#: src/preferences.rs:426
+msgid "Page layout preset; Custom uses the settings below"
+msgstr "Ajuste de página predefinido; Personalizada usa los ajustes de abajo"
+
+#: src/preferences.rs:428
+msgid "Custom"
+msgstr "Personalizada"
+
+#: src/preferences.rs:429
+msgid "Article"
+msgstr "Artículo"
+
+#: src/preferences.rs:430
+msgid "Manuscript"
+msgstr "Manuscrito"
+
+#: src/preferences.rs:431
+msgid "Letter"
+msgstr "Carta"
+
+#: src/preferences.rs:432
+msgid "Compact"
+msgstr "Compacta"
+
+#: src/preferences.rs:438
msgid "Monochrome prints everything in black on white"
msgstr "Monocromo imprime todo en negro sobre blanco"
-#: src/preferences.rs:409
+#: src/preferences.rs:443
msgid "Monochrome"
msgstr "Monocromo"
-#: src/preferences.rs:424
+#: src/preferences.rs:458
msgid "Font size"
msgstr "Tamaño de fuente"
-#: src/preferences.rs:425
+#: src/preferences.rs:459
msgid "Points"
msgstr "Puntos"
-#: src/preferences.rs:430
+#: src/preferences.rs:464
msgid "Monospace font"
msgstr "Fuente monoespaciada"
-#: src/preferences.rs:431
+#: src/preferences.rs:465
msgid "Used for code blocks and tables"
msgstr "Se usa en bloques de código y tablas"
-#: src/preferences.rs:440
+#: src/preferences.rs:474
msgid "Page size"
msgstr "Tamaño de página"
-#: src/preferences.rs:441
+#: src/preferences.rs:475
msgid "Paper"
msgstr "Papel"
-#: src/preferences.rs:446
+#: src/preferences.rs:480
msgid "Orientation"
msgstr "Orientación"
-#: src/preferences.rs:447
+#: src/preferences.rs:481
msgid "Paper orientation"
msgstr "Orientación del papel"
-#: src/preferences.rs:448
+#: src/preferences.rs:482
msgid "Portrait"
msgstr "Vertical"
-#: src/preferences.rs:448
+#: src/preferences.rs:482
msgid "Landscape"
msgstr "Horizontal"
+#: src/preferences.rs:487
+msgid "Stylesheet"
+msgstr "Hoja de estilo"
+
+#: src/preferences.rs:491
+msgid "Load…"
+msgstr "Cargar…"
+
+#: src/preferences.rs:496
+msgid "Use the template as is"
+msgstr "Usar la plantilla tal cual"
+
+#: src/preferences.rs:591
+msgid "CSS stylesheets"
+msgstr "Hojas de estilo CSS"
+
+#: src/preferences.rs:596
+msgid "Choose a stylesheet"
+msgstr "Elige una hoja de estilo"
+
#: src/file_manager.rs:52
msgid "Open Document"
msgstr "Abrir documento"
@@ -674,6 +713,10 @@ msgstr "Cerrar vallas abiertas"
msgid "View"
msgstr "Vista"
+#: src/shortcuts.ui:166
+msgid "Split View"
+msgstr "Vista dividida"
+
#: src/shortcuts.ui:184
msgid "Zoom In or Out"
msgstr "Ampliar o reducir"
@@ -682,6 +725,12 @@ msgstr "Ampliar o reducir"
msgid "Preferences"
msgstr "Preferencias"
+#~ msgid "Solarized Dark"
+#~ msgstr "Solarized oscuro"
+
+#~ msgid "Gruvbox Dark"
+#~ msgstr "Gruvbox oscuro"
+
#~ msgid "Close the unterminated code fences"
#~ msgstr "Cerrar las vallas de código abiertas"
@@ -729,9 +778,6 @@ msgstr "Preferencias"
#~ msgid "Dark Style"
#~ msgstr "Estilo oscuro"
-#~ msgid "Style"
-#~ msgstr "Estilo"
-
#~ msgid "Zoom Out"
#~ msgstr "Reducir"
diff --git a/po/scribe.pot b/po/scribe.pot
index b8263f1..ca569ea 100644
--- a/po/scribe.pot
+++ b/po/scribe.pot
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2026-10-19 14:53+0200\n"
+"POT-Creation-Date: 2026-10-27 06:01+0100\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME \n"
"Language-Team: LANGUAGE \n"
@@ -17,551 +17,595 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
-#: src/main.rs:238
+#: src/main.rs:277
msgid "Recover unsaved documents?"
msgstr ""
-#: src/main.rs:241
+#: src/main.rs:280
msgid "_Recover All"
msgstr ""
-#: src/main.rs:242
+#: src/main.rs:281
msgid "_Discard All"
msgstr ""
-#: src/main.rs:243
+#: src/main.rs:282
msgid "Decide Later"
msgstr ""
-#: src/window.rs:261 src/window.rs:701 src/window.rs:1151 src/window.rs:1423
+#: src/window.rs:290 src/window.rs:771 src/window.rs:1251 src/window.rs:1523
msgid "Untitled"
msgstr ""
-#: src/window.rs:304
+#: src/window.rs:334
msgid "Draft"
msgstr ""
-#: src/window.rs:308
+#: src/window.rs:338
msgid "Unsaved draft"
msgstr ""
-#: src/window.rs:398 src/window.rs:2210
+#: src/window.rs:428 src/window.rs:2316
#, rust-format
msgid "Save changes to “{name}”?"
msgstr ""
-#: src/window.rs:400 src/window.rs:2212
+#: src/window.rs:430 src/window.rs:2318
msgid "If you close without saving, you will lose what you have written."
msgstr ""
-#: src/window.rs:403 src/window.rs:980 src/window.rs:2215
+#: src/window.rs:433 src/window.rs:1052 src/window.rs:2321
msgid "_Cancel"
msgstr ""
-#: src/window.rs:404 src/window.rs:2216
+#: src/window.rs:434 src/window.rs:2322
msgid "_Discard"
msgstr ""
-#: src/window.rs:405 src/window.rs:747 src/window.rs:2217
+#: src/window.rs:435 src/window.rs:818 src/window.rs:2323
msgid "_Save"
msgstr ""
-#: src/window.rs:434 src/window.rs:468 src/window.rs:2247 src/window.rs:2275
+#: src/window.rs:464 src/window.rs:498 src/window.rs:2353 src/window.rs:2381
#, rust-format
msgid "Could not save: {e}"
msgstr ""
-#: src/window.rs:516
+#: src/window.rs:546
msgid "Documents"
msgstr ""
-#: src/window.rs:522
+#: src/window.rs:552
msgid "Filter recents…"
msgstr ""
-#: src/window.rs:551
+#: src/window.rs:581
msgid "Recents"
msgstr ""
-#: src/window.rs:553
+#: src/window.rs:583
msgid "Contents"
msgstr ""
-#: src/window.rs:596
+#: src/window.rs:641
msgid "Search"
msgstr ""
-#: src/window.rs:603
+#: src/window.rs:648
msgid "Match Case"
msgstr ""
-#: src/window.rs:607
+#: src/window.rs:652
msgid "Regular Expression"
msgstr ""
-#: src/window.rs:614 src/shortcuts.ui:114
+#: src/window.rs:659 src/shortcuts.ui:114
msgid "Find Previous"
msgstr ""
-#: src/window.rs:619 src/shortcuts.ui:108
+#: src/window.rs:664 src/shortcuts.ui:108
msgid "Find Next"
msgstr ""
-#: src/window.rs:627
+#: src/window.rs:671
msgid "Search & Replace"
msgstr ""
-#: src/window.rs:633 src/shortcuts.ui:102
+#: src/window.rs:685 src/shortcuts.ui:102
msgid "Replace"
msgstr ""
-#: src/window.rs:637
+#: src/window.rs:689
msgid "_Replace"
msgstr ""
-#: src/window.rs:641
+#: src/window.rs:693
msgid "Replace _All"
msgstr ""
-#: src/window.rs:693
+#: src/window.rs:748 src/window.rs:1127
+msgid "No recent documents"
+msgstr ""
+
+#: src/window.rs:763
msgid "_Open"
msgstr ""
-#: src/window.rs:695
+#: src/window.rs:765
msgid "Open a document (Ctrl+O)"
msgstr ""
-#: src/window.rs:709
+#: src/window.rs:779
msgid "New Tab (Ctrl+N)"
msgstr ""
-#: src/window.rs:717
+#: src/window.rs:787
msgid "Main menu"
msgstr ""
-#: src/window.rs:725
+#: src/window.rs:795
msgid "Find in the document (Ctrl+F)"
msgstr ""
-#: src/window.rs:738 src/shortcuts.ui:166
-msgid "Split View"
+#: src/window.rs:809
+msgid "Typewriter Mode (Ctrl+Shift+T)"
msgstr ""
-#: src/window.rs:748
+#: src/window.rs:819
msgid "Save _As…"
msgstr ""
-#: src/window.rs:749
+#: src/window.rs:820
msgid "Export to _PDF…"
msgstr ""
-#: src/window.rs:753 src/shortcuts.ui:160
+#: src/window.rs:821
+msgid "Print Pre_view…"
+msgstr ""
+
+#: src/window.rs:825 src/shortcuts.ui:160
msgid "Sidebar"
msgstr ""
-#: src/window.rs:754 src/shortcuts.ui:172
+#: src/window.rs:826 src/shortcuts.ui:172
msgid "Focus Mode"
msgstr ""
-#: src/window.rs:756 src/shortcuts.ui:178
+#: src/window.rs:828 src/shortcuts.ui:178
msgid "Typewriter Mode"
msgstr ""
-#: src/window.rs:762
+#: src/window.rs:834
msgid "_Preferences"
msgstr ""
-#: src/window.rs:764
+#: src/window.rs:836
msgid "_Keyboard Shortcuts"
msgstr ""
-#: src/window.rs:767
+#: src/window.rs:839
msgid "_About Scribe"
msgstr ""
-#: src/window.rs:841
+#: src/window.rs:913
#, rust-format
msgid "{0} of {1}"
msgstr ""
-#: src/window.rs:916 src/window.rs:960 src/window.rs:997
+#: src/window.rs:988 src/window.rs:1032 src/window.rs:1069
#, rust-format
msgid "Could not reload: {e}"
msgstr ""
-#: src/window.rs:923 src/window.rs:953
+#: src/window.rs:995 src/window.rs:1025
msgid "File deleted on disk; save to recreate it"
msgstr ""
-#: src/window.rs:945
+#: src/window.rs:1017
msgid "This document is not linked to a file"
msgstr ""
-#: src/window.rs:975
+#: src/window.rs:1047
#, rust-format
msgid "Reload “{name}” and discard changes?"
msgstr ""
-#: src/window.rs:977
+#: src/window.rs:1049
msgid "The file changed on disk. Reloading discards your unsaved edits."
msgstr ""
-#: src/window.rs:981
+#: src/window.rs:1053
msgid "_Reload"
msgstr ""
-#: src/window.rs:1033
+#: src/window.rs:1108
msgid "Not found"
msgstr ""
-#: src/window.rs:1051
-msgid "No recent documents"
-msgstr ""
-
-#: src/window.rs:1053
+#: src/window.rs:1129
msgid "No results"
msgstr ""
-#: src/window.rs:1333
+#: src/window.rs:1433
#, rust-format
msgid "Could not open: {e}"
msgstr ""
-#: src/window.rs:1601
+#: src/window.rs:1703
msgid "Code fences closed"
msgstr ""
-#: src/window.rs:1603
+#: src/window.rs:1705
msgid "No unclosed fences"
msgstr ""
-#: src/window.rs:1818
+#: src/window.rs:1927
msgid "That file is already open in another tab"
msgstr ""
-#: src/window.rs:1971
+#: src/window.rs:2061
#, rust-format
msgid "{n} occurrences replaced"
msgstr ""
-#: src/window.rs:2366
+#: src/window.rs:2472
#, rust-format
msgid "Could not autosave: {e}"
msgstr ""
-#: src/editor.rs:1336 src/editor.rs:1341 src/editor.rs:1346
+#: src/editor.rs:1356 src/editor.rs:1361 src/editor.rs:1366
msgid "Could not save the image"
msgstr ""
-#: src/editor.rs:1386
+#: src/editor.rs:1406
msgid "Save the document before pasting images"
msgstr ""
-#: src/editor.rs:1406 src/editor.rs:1423 src/editor.rs:1462
+#: src/editor.rs:1426 src/editor.rs:1443 src/editor.rs:1482
msgid "Save the document before dropping images"
msgstr ""
-#: src/editor.rs:1985
+#: src/editor.rs:1986
msgid "Ignore word"
msgstr ""
-#: src/editor.rs:1992
+#: src/editor.rs:1993
msgid "Add to dictionary"
msgstr ""
-#: src/editor.rs:2019
+#: src/editor.rs:2020
#, rust-format
msgid "Ctrl+click to open {url}"
msgstr ""
-#: src/preferences.rs:50
+#: src/preferences.rs:51
msgid "Appearance"
msgstr ""
-#: src/preferences.rs:55
+#: src/preferences.rs:56
msgid "Theme"
msgstr ""
-#: src/preferences.rs:58 src/preferences.rs:403
+#: src/preferences.rs:59 src/preferences.rs:437
msgid "Color scheme"
msgstr ""
-#: src/preferences.rs:59
+#: src/preferences.rs:60
msgid "Follows the system unless you force one"
msgstr ""
-#: src/preferences.rs:60 src/preferences.rs:406
+#: src/preferences.rs:61 src/preferences.rs:440
msgid "System"
msgstr ""
-#: src/preferences.rs:60 src/preferences.rs:80 src/preferences.rs:407
+#: src/preferences.rs:61 src/preferences.rs:441
msgid "Light"
msgstr ""
-#: src/preferences.rs:60 src/preferences.rs:81 src/preferences.rs:408
+#: src/preferences.rs:61 src/preferences.rs:442
msgid "Dark"
msgstr ""
-#: src/preferences.rs:76
-msgid "Text theme"
-msgstr ""
-
-#: src/preferences.rs:77
-msgid "Semantic colors of the document, applied live"
-msgstr ""
-
-#: src/preferences.rs:79
+#: src/preferences.rs:69
msgid "Automatic (follows the color scheme)"
msgstr ""
-#: src/preferences.rs:82
-msgid "Sepia"
-msgstr ""
-
-#: src/preferences.rs:83
-msgid "Solarized Dark"
+#: src/preferences.rs:88
+msgid "Text theme"
msgstr ""
-#: src/preferences.rs:84
-msgid "Gruvbox Dark"
+#: src/preferences.rs:89
+msgid "Semantic colors of the document, applied live"
msgstr ""
-#: src/preferences.rs:101
+#: src/preferences.rs:109
msgid "Text"
msgstr ""
-#: src/preferences.rs:102
+#: src/preferences.rs:110
msgid "Code and tables always use a monospaced font"
msgstr ""
-#: src/preferences.rs:106 src/preferences.rs:414
+#: src/preferences.rs:114 src/preferences.rs:448
msgid "Font family"
msgstr ""
-#: src/preferences.rs:107 src/preferences.rs:415
+#: src/preferences.rs:115 src/preferences.rs:449
msgid "Document body"
msgstr ""
-#: src/preferences.rs:109 src/preferences.rs:417
+#: src/preferences.rs:117 src/preferences.rs:451
msgid "Sans (Cantarell)"
msgstr ""
-#: src/preferences.rs:110 src/preferences.rs:418
+#: src/preferences.rs:118 src/preferences.rs:452
msgid "Serif"
msgstr ""
-#: src/preferences.rs:111 src/preferences.rs:419
+#: src/preferences.rs:119 src/preferences.rs:453
msgid "Monospace"
msgstr ""
-#: src/preferences.rs:116
+#: src/preferences.rs:124
msgid "Size"
msgstr ""
-#: src/preferences.rs:117
+#: src/preferences.rs:125
msgid "Pixels"
msgstr ""
-#: src/preferences.rs:122
+#: src/preferences.rs:130
msgid "Line spacing"
msgstr ""
-#: src/preferences.rs:123
+#: src/preferences.rs:131
msgid "Multiplier on the font size"
msgstr ""
-#: src/preferences.rs:128
+#: src/preferences.rs:136
msgid "Column width"
msgstr ""
-#: src/preferences.rs:129
+#: src/preferences.rs:137
msgid "Maximum text width, in pixels"
msgstr ""
-#: src/preferences.rs:192
+#: src/preferences.rs:200
msgid "Editor"
msgstr ""
-#: src/preferences.rs:197
+#: src/preferences.rs:205
msgid "Markup"
msgstr ""
-#: src/preferences.rs:199
+#: src/preferences.rs:207
msgid "What to do with asterisks, hash marks and URLs while you write"
msgstr ""
-#: src/preferences.rs:209 src/preferences.rs:220
+#: src/preferences.rs:217 src/preferences.rs:228
msgid "Markdown marks"
msgstr ""
-#: src/preferences.rs:210
+#: src/preferences.rs:218
msgid "“On focus” reveals them only on the cursor line"
msgstr ""
-#: src/preferences.rs:212
+#: src/preferences.rs:220
msgid "Always hide"
msgstr ""
-#: src/preferences.rs:213
+#: src/preferences.rs:221
msgid "Show on focus"
msgstr ""
-#: src/preferences.rs:214 src/preferences.rs:225
+#: src/preferences.rs:222 src/preferences.rs:233
msgid "Always dim"
msgstr ""
-#: src/preferences.rs:221
+#: src/preferences.rs:229
msgid "Hiding text still crashes this GTK: the marks shrink instead"
msgstr ""
-#: src/preferences.rs:223
+#: src/preferences.rs:231
msgid "Always hide (shrinks the marks; GTK cannot hide them yet)"
msgstr ""
-#: src/preferences.rs:224
+#: src/preferences.rs:232
msgid "Show on focus (shrinks the marks; GTK cannot hide them yet)"
msgstr ""
-#: src/preferences.rs:234
+#: src/preferences.rs:242
msgid "Writing"
msgstr ""
-#: src/preferences.rs:237
+#: src/preferences.rs:245
msgid "Continue lists"
msgstr ""
-#: src/preferences.rs:239
+#: src/preferences.rs:247
msgid "Enter repeats the dash or number; on an empty item, it closes the list"
msgstr ""
-#: src/preferences.rs:244
+#: src/preferences.rs:252
msgid "Focus mode"
msgstr ""
-#: src/preferences.rs:245
+#: src/preferences.rs:253
msgid "Dims everything except the current paragraph"
msgstr ""
-#: src/preferences.rs:249
+#: src/preferences.rs:257
msgid "Typewriter mode"
msgstr ""
-#: src/preferences.rs:250
+#: src/preferences.rs:258
msgid "Keeps the cursor line vertically centered"
msgstr ""
-#: src/preferences.rs:254
+#: src/preferences.rs:262
msgid "Spell checking"
msgstr ""
-#: src/preferences.rs:256
+#: src/preferences.rs:264
msgid "Underlines misspelt words; right-click offers fixes"
msgstr ""
-#: src/preferences.rs:261
+#: src/preferences.rs:269
msgid "Tab width"
msgstr ""
-#: src/preferences.rs:262
+#: src/preferences.rs:270
msgid "Spaces per indent level"
msgstr ""
-#: src/preferences.rs:273
+#: src/preferences.rs:281
msgid "Saving"
msgstr ""
-#: src/preferences.rs:276
+#: src/preferences.rs:284
msgid "Autosave"
msgstr ""
-#: src/preferences.rs:277
+#: src/preferences.rs:285
msgid "Only when the document already has a file"
msgstr ""
-#: src/preferences.rs:281
+#: src/preferences.rs:289
msgid "Interval"
msgstr ""
-#: src/preferences.rs:282
+#: src/preferences.rs:290
msgid "Seconds between saves"
msgstr ""
-#: src/preferences.rs:299
+#: src/preferences.rs:307
msgid "History"
msgstr ""
-#: src/preferences.rs:302
+#: src/preferences.rs:310
msgid "Clear"
msgstr ""
-#: src/preferences.rs:307
+#: src/preferences.rs:315
msgid "Recent documents"
msgstr ""
-#: src/preferences.rs:308
+#: src/preferences.rs:316
msgid "Clears the list shown in the sidebar"
msgstr ""
-#: src/preferences.rs:391
+#: src/preferences.rs:401
+msgid "None"
+msgstr ""
+
+#: src/preferences.rs:413
msgid "Export"
msgstr ""
-#: src/preferences.rs:396
+#: src/preferences.rs:418
msgid "PDF"
msgstr ""
-#: src/preferences.rs:398
+#: src/preferences.rs:420
msgid "These settings apply to PDF export only, not to the editor view"
msgstr ""
-#: src/preferences.rs:404
+#: src/preferences.rs:425
+msgid "Template"
+msgstr ""
+
+#: src/preferences.rs:426
+msgid "Page layout preset; Custom uses the settings below"
+msgstr ""
+
+#: src/preferences.rs:428
+msgid "Custom"
+msgstr ""
+
+#: src/preferences.rs:429
+msgid "Article"
+msgstr ""
+
+#: src/preferences.rs:430
+msgid "Manuscript"
+msgstr ""
+
+#: src/preferences.rs:431
+msgid "Letter"
+msgstr ""
+
+#: src/preferences.rs:432
+msgid "Compact"
+msgstr ""
+
+#: src/preferences.rs:438
msgid "Monochrome prints everything in black on white"
msgstr ""
-#: src/preferences.rs:409
+#: src/preferences.rs:443
msgid "Monochrome"
msgstr ""
-#: src/preferences.rs:424
+#: src/preferences.rs:458
msgid "Font size"
msgstr ""
-#: src/preferences.rs:425
+#: src/preferences.rs:459
msgid "Points"
msgstr ""
-#: src/preferences.rs:430
+#: src/preferences.rs:464
msgid "Monospace font"
msgstr ""
-#: src/preferences.rs:431
+#: src/preferences.rs:465
msgid "Used for code blocks and tables"
msgstr ""
-#: src/preferences.rs:440
+#: src/preferences.rs:474
msgid "Page size"
msgstr ""
-#: src/preferences.rs:441
+#: src/preferences.rs:475
msgid "Paper"
msgstr ""
-#: src/preferences.rs:446
+#: src/preferences.rs:480
msgid "Orientation"
msgstr ""
-#: src/preferences.rs:447
+#: src/preferences.rs:481
msgid "Paper orientation"
msgstr ""
-#: src/preferences.rs:448
+#: src/preferences.rs:482
msgid "Portrait"
msgstr ""
-#: src/preferences.rs:448
+#: src/preferences.rs:482
msgid "Landscape"
msgstr ""
+#: src/preferences.rs:487
+msgid "Stylesheet"
+msgstr ""
+
+#: src/preferences.rs:491
+msgid "Load…"
+msgstr ""
+
+#: src/preferences.rs:496
+msgid "Use the template as is"
+msgstr ""
+
+#: src/preferences.rs:591
+msgid "CSS stylesheets"
+msgstr ""
+
+#: src/preferences.rs:596
+msgid "Choose a stylesheet"
+msgstr ""
+
#: src/file_manager.rs:52
msgid "Open Document"
msgstr ""
@@ -666,6 +710,10 @@ msgstr ""
msgid "View"
msgstr ""
+#: src/shortcuts.ui:166
+msgid "Split View"
+msgstr ""
+
#: src/shortcuts.ui:184
msgid "Zoom In or Out"
msgstr ""
diff --git a/src/export_css.rs b/src/export_css.rs
new file mode 100644
index 0000000..8661080
--- /dev/null
+++ b/src/export_css.rs
@@ -0,0 +1,735 @@
+//! Subconjunto de CSS para el export a PDF.
+//!
+//! El export dibuja y pagina con cairo, así que no interpreta CSS de verdad:
+//! este módulo lee un conjunto reducido de reglas y devuelve los valores que la
+//! maquetación ya sabe aplicar (márgenes de página, tipografía, interlineado,
+//! espaciado entre bloques y colores). Todo lo que no encaja en ese modelo se
+//! ignora sin abortar la exportación, y los nombres de propiedad descartados se
+//! acumulan en [`Ajustes::ignoradas`] para avisar una sola vez.
+
+use crate::settings::{ExportMonoFont, FontFamily};
+
+/// Color en componentes 0..1.
+pub type Color = (f64, f64, f64);
+
+/// Puntos por milímetro.
+const PT_POR_MM: f64 = 72.0 / 25.4;
+/// Puntos por centímetro.
+const PT_POR_CM: f64 = 72.0 / 2.54;
+/// Puntos por pulgada.
+const PT_POR_PX: f64 = 0.75;
+
+/// Márgenes de página en puntos. Un `None` deja el margen que traiga el papel.
+#[derive(Debug, Clone, Copy, PartialEq, Default)]
+pub struct MargenesCss {
+ pub superior: Option,
+ pub inferior: Option,
+ pub izquierda: Option,
+ pub derecha: Option,
+}
+
+impl MargenesCss {
+ const fn uniforme(margen: f64) -> Self {
+ Self {
+ superior: Some(margen),
+ inferior: Some(margen),
+ izquierda: Some(margen),
+ derecha: Some(margen),
+ }
+ }
+}
+
+/// Colores que la hoja impone sobre la paleta resuelta.
+#[derive(Debug, Clone, Copy, PartialEq, Default)]
+pub struct Colores {
+ pub fondo: Option,
+ pub texto: Option,
+ pub cabecera: Option,
+ pub codigo: Option,
+ pub codigo_fondo: Option,
+ pub cita: Option,
+ pub enlace: Option,
+ pub tabla_fondo: Option,
+}
+
+/// Valores que una hoja de estilo impone a la maquetación.
+#[derive(Debug, Clone, PartialEq, Default)]
+pub struct Ajustes {
+ pub margenes: MargenesCss,
+ pub familia: Option,
+ pub fuente_mono: Option,
+ pub tamano_cuerpo: Option,
+ /// Tamaño absoluto pedido para cada cabecera (índices 1..=4).
+ pub tamanos_cabecera: [Option; 5],
+ pub interlinea: Option,
+ pub espacio_bloque: Option,
+ pub colores: Colores,
+ /// Propiedades que el export no sabe aplicar (sin repetir).
+ pub ignoradas: Vec,
+}
+
+impl Ajustes {
+ /// ¿La hoja no impone nada? Entonces el export no cambia de aspecto.
+ pub fn es_vacio(&self) -> bool {
+ *self == Self::default()
+ }
+}
+
+/// Lee y analiza una hoja de estilo.
+pub fn cargar(ruta: &std::path::Path) -> std::io::Result {
+ let css = std::fs::read_to_string(ruta)?;
+ Ok(analizar(&css))
+}
+
+/// Analiza una hoja de estilo y devuelve los valores aplicables.
+pub fn analizar(css: &str) -> Ajustes {
+ let mut ajustes = Ajustes::default();
+ for (preludio, cuerpo) in reglas(&sin_comentarios(css)) {
+ let preludio = preludio.trim();
+ if preludio.is_empty() {
+ continue;
+ }
+ if preludio.starts_with('@') {
+ if preludio.eq_ignore_ascii_case("@page") {
+ for (prop, valor) in declaraciones(&cuerpo) {
+ aplicar_pagina(&mut ajustes, &prop, &valor);
+ }
+ }
+ continue;
+ }
+ let elementos: Vec = preludio.split(',').filter_map(elemento).collect();
+ if elementos.is_empty() {
+ continue;
+ }
+ for (prop, valor) in declaraciones(&cuerpo) {
+ let mut aplicada = false;
+ for el in &elementos {
+ aplicada |= aplicar_regla(&mut ajustes, *el, &prop, &valor);
+ }
+ if !aplicada && !ajustes.ignoradas.contains(&prop) {
+ ajustes.ignoradas.push(prop);
+ }
+ }
+ }
+ ajustes
+}
+
+/// Elementos del documento que la hoja puede estilizar.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum Elemento {
+ Raiz,
+ Parrafo,
+ Cabecera(u8),
+ Codigo,
+ Cita,
+ Enlace,
+ Tabla,
+}
+
+/// Quita los comentarios `/* ... */` y devuelve el resto como caracteres.
+fn sin_comentarios(css: &str) -> Vec {
+ let mut salida = Vec::with_capacity(css.len());
+ let mut i = 0;
+ let chars: Vec = css.chars().collect();
+ while i < chars.len() {
+ if chars[i] == '/' && chars.get(i + 1) == Some(&'*') {
+ i += 2;
+ while i < chars.len() && !(chars[i] == '*' && chars.get(i + 1) == Some(&'/')) {
+ i += 1;
+ }
+ i = (i + 2).min(chars.len());
+ continue;
+ }
+ salida.push(chars[i]);
+ i += 1;
+ }
+ salida
+}
+
+/// Trocea la hoja en pares (preludio, cuerpo de la regla).
+fn reglas(css: &[char]) -> Vec<(String, String)> {
+ let mut salida = Vec::new();
+ let mut preludio = String::new();
+ let mut i = 0;
+ while i < css.len() {
+ match css[i] {
+ '{' => {
+ let (cuerpo, fin) = hasta_llave(css, i + 1);
+ salida.push((preludio.trim().to_string(), cuerpo));
+ preludio.clear();
+ i = fin;
+ }
+ '}' | ';' => {
+ preludio.clear();
+ i += 1;
+ }
+ c => {
+ preludio.push(c);
+ i += 1;
+ }
+ }
+ }
+ salida
+}
+
+/// Cuerpo de la regla que empieza en `desde`, respetando llaves anidadas.
+fn hasta_llave(css: &[char], desde: usize) -> (String, usize) {
+ let mut nivel = 1;
+ let mut cuerpo = String::new();
+ let mut i = desde;
+ while i < css.len() {
+ match css[i] {
+ '{' => {
+ nivel += 1;
+ cuerpo.push('{');
+ }
+ '}' => {
+ nivel -= 1;
+ if nivel == 0 {
+ return (cuerpo, i + 1);
+ }
+ cuerpo.push('}');
+ }
+ c => cuerpo.push(c),
+ }
+ i += 1;
+ }
+ (cuerpo, i)
+}
+
+/// Declaraciones `propiedad: valor` de un cuerpo de regla.
+fn declaraciones(cuerpo: &str) -> Vec<(String, String)> {
+ cuerpo
+ .split(';')
+ .filter_map(|d| d.split_once(':'))
+ .map(|(p, v)| (p.trim().to_ascii_lowercase(), v.trim().to_string()))
+ .filter(|(p, _)| !p.is_empty())
+ .collect()
+}
+
+/// Quita un `!important` final del valor.
+fn sin_importante(valor: &str) -> String {
+ let mut v = valor.trim().to_string();
+ const MARCA: &str = "!important";
+ if v.to_ascii_lowercase().ends_with(MARCA) {
+ v.truncate(v.len() - MARCA.len());
+ }
+ v.trim().to_string()
+}
+
+/// Elemento al que apunta un selector. Un selector que no se reconoce se
+/// descarta, y con él sus declaraciones.
+fn elemento(selector: &str) -> Option {
+ let ultimo = selector.split_whitespace().next_back()?;
+ if ultimo.eq_ignore_ascii_case(":root") || ultimo == "*" {
+ return Some(Elemento::Raiz);
+ }
+ // Se queda con la parte anterior a clases, ids, atributos y pseudoclases.
+ let base: String = ultimo
+ .chars()
+ .take_while(|c| c.is_alphanumeric() || *c == '-')
+ .collect();
+ match base.to_ascii_lowercase().as_str() {
+ "html" | "body" => Some(Elemento::Raiz),
+ "p" => Some(Elemento::Parrafo),
+ "h1" => Some(Elemento::Cabecera(1)),
+ "h2" => Some(Elemento::Cabecera(2)),
+ "h3" => Some(Elemento::Cabecera(3)),
+ "h4" => Some(Elemento::Cabecera(4)),
+ "pre" | "code" => Some(Elemento::Codigo),
+ "blockquote" => Some(Elemento::Cita),
+ "a" => Some(Elemento::Enlace),
+ "table" => Some(Elemento::Tabla),
+ _ => None,
+ }
+}
+
+/// Aplica una declaración de una regla normal. Devuelve si se ha usado.
+fn aplicar_regla(ajustes: &mut Ajustes, el: Elemento, prop: &str, valor: &str) -> bool {
+ let valor = sin_importante(valor);
+ if prop == "--block-spacing" {
+ if let Some(v) = longitud(&valor) {
+ ajustes.espacio_bloque = Some(v);
+ return true;
+ }
+ return false;
+ }
+ match (el, prop) {
+ (Elemento::Raiz, "font-family") => {
+ if let Some(f) = familia(&valor) {
+ ajustes.familia = Some(f);
+ }
+ }
+ (Elemento::Codigo, "font-family") => {
+ if let Some(f) = fuente_mono(&valor) {
+ ajustes.fuente_mono = Some(f);
+ }
+ }
+ (Elemento::Raiz, "font-size") => {
+ if let Some(v) = longitud(&valor).filter(|v| *v > 0.0) {
+ ajustes.tamano_cuerpo = Some(v);
+ }
+ }
+ (Elemento::Cabecera(n), "font-size") => {
+ if let Some(v) = longitud(&valor).filter(|v| *v > 0.0) {
+ ajustes.tamanos_cabecera[n as usize] = Some(v);
+ }
+ }
+ (Elemento::Raiz | Elemento::Parrafo, "line-height") => {
+ if let Some(v) = interlinea(&valor) {
+ ajustes.interlinea = Some(v);
+ }
+ }
+ (Elemento::Raiz | Elemento::Parrafo, "margin-bottom") => {
+ if let Some(v) = longitud(&valor) {
+ ajustes.espacio_bloque = Some(v);
+ }
+ }
+ (Elemento::Raiz, "background-color") => {
+ if let Some(c) = color(&valor) {
+ ajustes.colores.fondo = Some(c);
+ }
+ }
+ (Elemento::Codigo, "background-color") => {
+ if let Some(c) = color(&valor) {
+ ajustes.colores.codigo_fondo = Some(c);
+ }
+ }
+ (Elemento::Tabla, "background-color") => {
+ if let Some(c) = color(&valor) {
+ ajustes.colores.tabla_fondo = Some(c);
+ }
+ }
+ (_, "color") => {
+ if let Some(c) = color(&valor) {
+ *campo_color(&mut ajustes.colores, el) = Some(c);
+ }
+ }
+ _ => return false,
+ }
+ true
+}
+
+/// Campo de la paleta que alimenta el `color` de cada elemento.
+fn campo_color(colores: &mut Colores, el: Elemento) -> &mut Option {
+ match el {
+ Elemento::Raiz | Elemento::Parrafo | Elemento::Tabla => &mut colores.texto,
+ Elemento::Cabecera(_) => &mut colores.cabecera,
+ Elemento::Codigo => &mut colores.codigo,
+ Elemento::Cita => &mut colores.cita,
+ Elemento::Enlace => &mut colores.enlace,
+ }
+}
+
+/// Aplica una declaración de `@page`.
+fn aplicar_pagina(ajustes: &mut Ajustes, prop: &str, valor: &str) {
+ let valor = sin_importante(valor);
+ match prop {
+ "margin" => {
+ let valores: Vec = valor.split_whitespace().filter_map(longitud).collect();
+ let lados = valor.split_whitespace().count();
+ if valores.len() != lados {
+ return;
+ }
+ ajustes.margenes = match valores.len() {
+ 1 => MargenesCss::uniforme(valores[0]),
+ 2 => MargenesCss {
+ superior: Some(valores[0]),
+ inferior: Some(valores[0]),
+ izquierda: Some(valores[1]),
+ derecha: Some(valores[1]),
+ },
+ 3 => MargenesCss {
+ superior: Some(valores[0]),
+ inferior: Some(valores[2]),
+ izquierda: Some(valores[1]),
+ derecha: Some(valores[1]),
+ },
+ 4 => MargenesCss {
+ superior: Some(valores[0]),
+ inferior: Some(valores[2]),
+ izquierda: Some(valores[3]),
+ derecha: Some(valores[1]),
+ },
+ _ => return,
+ };
+ }
+ "margin-top" => {
+ if let Some(v) = longitud(&valor) {
+ ajustes.margenes.superior = Some(v);
+ }
+ }
+ "margin-bottom" => {
+ if let Some(v) = longitud(&valor) {
+ ajustes.margenes.inferior = Some(v);
+ }
+ }
+ "margin-left" => {
+ if let Some(v) = longitud(&valor) {
+ ajustes.margenes.izquierda = Some(v);
+ }
+ }
+ "margin-right" => {
+ if let Some(v) = longitud(&valor) {
+ ajustes.margenes.derecha = Some(v);
+ }
+ }
+ _ => {}
+ }
+}
+
+/// Longitud CSS convertida a puntos. Se aceptan pt, mm, cm, in y px, además de
+/// números sin unidad (como el 0 de CSS). Los valores negativos no encajan en
+/// la maquetación y se descartan.
+fn longitud(valor: &str) -> Option {
+ let v = valor.trim().to_ascii_lowercase();
+ if v.is_empty() {
+ return None;
+ }
+ let (numero, factor) = if let Some(n) = v.strip_suffix("mm") {
+ (n, PT_POR_MM)
+ } else if let Some(n) = v.strip_suffix("cm") {
+ (n, PT_POR_CM)
+ } else if let Some(n) = v.strip_suffix("in") {
+ (n, 72.0)
+ } else if let Some(n) = v.strip_suffix("px") {
+ (n, PT_POR_PX)
+ } else if let Some(n) = v.strip_suffix("pt") {
+ (n, 1.0)
+ } else {
+ (v.as_str(), 1.0)
+ };
+ let n: f64 = numero.trim().parse().ok()?;
+ if !n.is_finite() || n < 0.0 {
+ return None;
+ }
+ Some(n * factor)
+}
+
+/// Interlineado: número sin unidad o porcentaje. Fuera de un rango razonable se
+/// descarta para no romper la paginación.
+fn interlinea(valor: &str) -> Option {
+ let v = valor.trim();
+ let n = if let Some(pct) = v.strip_suffix('%') {
+ pct.trim().parse::().ok()? / 100.0
+ } else {
+ v.parse::().ok()?
+ };
+ if !n.is_finite() || !(0.5..=5.0).contains(&n) {
+ return None;
+ }
+ Some(n)
+}
+
+/// Color CSS: `#rgb`, `#rrggbb`, `rgb()`, `rgba()` o uno de los nombres básicos.
+fn color(valor: &str) -> Option {
+ let v = valor.trim().to_ascii_lowercase();
+ if let Some(resto) = v.strip_prefix('#') {
+ return color_hex(resto);
+ }
+ let rgb = v
+ .strip_prefix("rgba(")
+ .or_else(|| v.strip_prefix("rgb("))
+ .and_then(|r| r.strip_suffix(')'));
+ if let Some(resto) = rgb {
+ let partes: Vec<&str> = resto
+ .split([',', ' '])
+ .map(str::trim)
+ .filter(|p| !p.is_empty())
+ .collect();
+ if partes.len() < 3 {
+ return None;
+ }
+ let canal = |p: &str| -> Option {
+ if let Some(pct) = p.strip_suffix('%') {
+ pct.parse::().ok().map(|n| (n / 100.0).clamp(0.0, 1.0))
+ } else {
+ p.parse::().ok().map(|n| (n / 255.0).clamp(0.0, 1.0))
+ }
+ };
+ return Some((canal(partes[0])?, canal(partes[1])?, canal(partes[2])?));
+ }
+ let (r, g, b) = nombre_color(&v)?;
+ Some((r as f64 / 255.0, g as f64 / 255.0, b as f64 / 255.0))
+}
+
+/// Color a partir de los dígitos hexadecimales (sin la almohadilla).
+fn color_hex(hex: &str) -> Option {
+ let digitos: Vec = hex
+ .chars()
+ .map(|c| c.to_digit(16))
+ .collect::