From 4cec6c647650b51ed20d5bb4c64807d1928abbb2 Mon Sep 17 00:00:00 2001 From: gnacho Date: Tue, 27 Oct 2026 05:56:36 +0100 Subject: [PATCH 1/3] feat(export): add named page templates --- data/io.github.gnacho.Scribe.gschema.xml | 11 + src/export_pdf.rs | 602 ++++++++++++++++++++--- src/settings.rs | 63 +++ tests/export_pdf.rs | 1 + 4 files changed, 597 insertions(+), 80 deletions(-) diff --git a/data/io.github.gnacho.Scribe.gschema.xml b/data/io.github.gnacho.Scribe.gschema.xml index 0184c2d..a7ee879 100644 --- a/data/io.github.gnacho.Scribe.gschema.xml +++ b/data/io.github.gnacho.Scribe.gschema.xml @@ -34,6 +34,13 @@ + + + + + + + @@ -152,5 +159,9 @@ 'portrait' PDF export page orientation + + 'custom' + PDF export page template + diff --git a/src/export_pdf.rs b/src/export_pdf.rs index 1b6cf75..5f4e0d7 100644 --- a/src/export_pdf.rs +++ b/src/export_pdf.rs @@ -50,14 +50,16 @@ use gtk4::prelude::*; use crate::markdown_render::{analyze, Analysis, Ornament, SpanKind}; use crate::settings::{ - AppSettings, ExportColorScheme, ExportMonoFont, ExportOrientation, ExportPageSize, FontFamily, + AppSettings, ExportColorScheme, ExportMonoFont, ExportOrientation, ExportPageSize, + ExportTemplate, FontFamily, }; // --------------------------------------------------------------------------- // Constantes de maquetación // --------------------------------------------------------------------------- -/// Altura de línea relativa al tamaño del bloque (cuerpo = 1.4). +/// Altura de línea relativa al tamaño del bloque (cuerpo = 1.4). Es el valor +/// de las plantillas Custom, Article y Letter. const INTERLINEA: f64 = 1.4; /// Separación vertical entre bloques consecutivos (puntos). const ESPACIO_BLOQUE: f64 = 10.0; @@ -75,6 +77,15 @@ const IMAGEN_ALTO: f64 = 42.0; const INDENT_LISTA: f64 = 18.0; /// Sangrado de la barra de cita (puntos). const INDENT_CITA: f64 = 14.0; +/// Escala de las cabeceras por nivel (índice 0 sin uso): misma proporción que +/// la vista del editor. +const ESCALA_CABECERA: [f64; 5] = [1.0, 1.9, 1.5, 1.25, 1.1]; +/// Espaciado extra de una cabecera, como fracción del cuerpo (índice 0 sin uso). +const ESPACIADO_CABECERA: [f64; 5] = [0.0, 1.0, 0.8, 0.6, 0.4]; +/// Alto de la banda de cabecera/pie, dentro del área de contenido (puntos). +const BANDA_ADORNO: f64 = 18.0; +/// Cuerpo de los adornos de cabecera y pie. +const TAMANO_ADORNO: f64 = 8.0; // --------------------------------------------------------------------------- // Configuración de exportación @@ -90,6 +101,7 @@ pub struct ExportConfig { pub fuente_mono: ExportMonoFont, pub papel: ExportPageSize, pub orientacion: ExportOrientation, + pub template: ExportTemplate, } impl ExportConfig { @@ -103,6 +115,188 @@ impl ExportConfig { fuente_mono: s.export_mono_font(), papel: s.export_page_size(), orientacion: s.export_orientation(), + template: s.export_template(), + } + } + + /// Aplica la tipografía y el esquema que impone la plantilla, dejando + /// intactos los ajustes granulares cuando la plantilla no los fija. + fn con_plantilla(&self, plantilla: &Plantilla) -> Self { + let mut config = self.clone(); + if let Some(familia) = plantilla.familia { + config.familia_cuerpo = familia; + } + if let Some(tamano) = plantilla.tamano { + config.tamano_cuerpo = tamano; + } + if let Some(esquema) = plantilla.esquema { + config.esquema = esquema; + } + config + } +} + +// --------------------------------------------------------------------------- +// Plantillas de página +// --------------------------------------------------------------------------- + +/// Márgenes de página en puntos. +#[derive(Debug, Clone, Copy, PartialEq)] +struct Margenes { + superior: f64, + inferior: f64, + izquierda: f64, + derecha: f64, +} + +impl Margenes { + /// Márgenes iguales en los cuatro lados. + const fn simetricos(margen: f64) -> Self { + Self { + superior: margen, + inferior: margen, + izquierda: margen, + derecha: margen, + } + } +} + +/// Contenido de la banda de cabecera o pie de página. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Adorno { + Nada, + /// Título del documento (en la cabecera). + Titulo, + /// Número de página «n / total» (en el pie). + NumeroPagina, +} + +/// Valores de maquetación resueltos de una plantilla. Los `None` significan +/// "lo que traiga el ajuste granular" o "el margen por defecto del papel". +#[derive(Debug, Clone, Copy)] +struct Plantilla { + margenes: Option, + interlinea: f64, + espacio_bloque: f64, + /// Escala de cada nivel de cabecera (índices 1..=4). + escala: [f64; 5], + /// Espaciado extra de cabecera como fracción del cuerpo (1..=4). + espaciado: [f64; 5], + cabecera: Adorno, + pie: Adorno, + bloque_titulo: bool, + familia: Option, + tamano: Option, + esquema: Option, +} + +impl Plantilla { + /// Resuelve la plantilla elegida. Custom no fija nada: reproduce la salida + /// de siempre con los ajustes granulares del usuario. + fn desde(config: &ExportConfig) -> Self { + match config.template { + ExportTemplate::Custom => Self::custom(), + ExportTemplate::Article => Self::article(), + ExportTemplate::Manuscript => Self::manuscript(), + ExportTemplate::Letter => Self::letter(), + ExportTemplate::Compact => Self::compact(), + } + } + + fn custom() -> Self { + Self { + margenes: None, + interlinea: INTERLINEA, + espacio_bloque: ESPACIO_BLOQUE, + escala: ESCALA_CABECERA, + espaciado: ESPACIADO_CABECERA, + cabecera: Adorno::Nada, + pie: Adorno::Nada, + bloque_titulo: false, + familia: None, + tamano: None, + esquema: None, + } + } + + /// Artículo: los valores de siempre, sin adornos ni portada. + fn article() -> Self { + Self { + familia: Some(FontFamily::Sans), + tamano: Some(11.0), + esquema: Some(ExportColorScheme::Light), + ..Self::custom() + } + } + + /// Manuscrito: serif, doble espacio, márgenes de una pulgada, portada con + /// el título, cabecera con el título y número de página al pie. + fn manuscript() -> Self { + Self { + margenes: Some(Margenes::simetricos(72.0)), + interlinea: 2.0, + espacio_bloque: 12.0, + escala: [1.0, 1.5, 1.3, 1.15, 1.05], + espaciado: [0.0, 1.2, 1.0, 0.8, 0.6], + cabecera: Adorno::Titulo, + pie: Adorno::NumeroPagina, + bloque_titulo: true, + familia: Some(FontFamily::Serif), + tamano: Some(12.0), + esquema: Some(ExportColorScheme::Light), + } + } + + /// Carta: como el artículo pero con márgenes más ajustados y sin portada. + fn letter() -> Self { + Self { + margenes: Some(Margenes::simetricos(54.0)), + ..Self::article() + } + } + + /// Compacta: para notas técnicas, cuerpo menor y espaciado apretado. + fn compact() -> Self { + Self { + margenes: Some(Margenes::simetricos(36.0)), + interlinea: 1.25, + espacio_bloque: 7.0, + escala: [1.0, 1.5, 1.3, 1.15, 1.05], + espaciado: [0.0, 0.8, 0.65, 0.5, 0.35], + cabecera: Adorno::Nada, + pie: Adorno::NumeroPagina, + bloque_titulo: false, + familia: Some(FontFamily::Sans), + tamano: Some(10.0), + esquema: Some(ExportColorScheme::Light), + } + } + + /// Escala de una cabecera por nivel. + fn escala_cabecera(&self, nivel: u8) -> f64 { + self.escala.get(nivel as usize).copied().unwrap_or(1.0) + } + + /// Espaciado extra de una cabecera (puntos). + fn espaciado_cabecera(&self, nivel: u8, tamano_cuerpo: f64) -> f64 { + tamano_cuerpo * self.espaciado.get(nivel as usize).copied().unwrap_or(0.4) + } + + /// Banda reservada arriba para la cabecera. + fn banda_superior(&self) -> f64 { + if self.cabecera == Adorno::Nada { + 0.0 + } else { + BANDA_ADORNO + } + } + + /// Banda reservada abajo para el pie. + fn banda_inferior(&self) -> f64 { + if self.pie == Adorno::Nada { + 0.0 + } else { + BANDA_ADORNO } } } @@ -943,6 +1137,7 @@ pub fn planificar_paginas(bloques: &[Bloque], alto_util: f64) -> Vec { // Medición y dibujado con cairo // --------------------------------------------------------------------------- +/// Familia tipográfica del cuerpo. fn familia_cuerpo(ff: FontFamily) -> &'static str { match ff { FontFamily::Sans => "Cantarell", @@ -951,17 +1146,6 @@ fn familia_cuerpo(ff: FontFamily) -> &'static str { } } -/// Escala de las cabeceras (misma proporción que la vista del editor). -fn escala_cabecera(nivel: u8) -> f64 { - match nivel { - 1 => 1.9, - 2 => 1.5, - 3 => 1.25, - 4 => 1.1, - _ => 1.0, - } -} - /// Familia y tamaño de fuente para un estilo dado, a partir del tamaño del /// bloque. El código usa la monoespaciada al 90% del cuerpo (no del bloque). fn fuente_para(config: &ExportConfig, estilo: Estilo, tamano_bloque: f64) -> (&'static str, f64) { @@ -973,10 +1157,14 @@ fn fuente_para(config: &ExportConfig, estilo: Estilo, tamano_bloque: f64) -> (&' } /// Tamaño y estilo base de un bloque. -fn tamano_y_estilo(tipo: TipoBloque, config: &ExportConfig) -> (f64, Estilo) { +fn tamano_y_estilo( + tipo: TipoBloque, + config: &ExportConfig, + plantilla: &Plantilla, +) -> (f64, Estilo) { match tipo { TipoBloque::Cabecera { nivel } => ( - config.tamano_cuerpo * escala_cabecera(nivel), + config.tamano_cuerpo * plantilla.escala_cabecera(nivel), Estilo { negrita: true, ..Default::default() @@ -1081,16 +1269,6 @@ fn envolver_frases( lineas } -/// Espaciado extra (arriba + abajo) de una cabecera. -fn espaciado_cabecera(nivel: u8, config: &ExportConfig) -> f64 { - match nivel { - 1 => config.tamano_cuerpo * 1.0, - 2 => config.tamano_cuerpo * 0.8, - 3 => config.tamano_cuerpo * 0.6, - _ => config.tamano_cuerpo * 0.4, - } -} - /// Alto de una fila de tabla: máximo de líneas visuales entre sus celdas. fn alto_fila_tabla(fila: &[Vec>], alto_linea: f64) -> f64 { let lineas = fila.iter().map(Vec::len).max().unwrap_or(0).max(1); @@ -1098,14 +1276,14 @@ fn alto_fila_tabla(fila: &[Vec>], alto_linea: f64) -> f64 { } /// Alto del contenido de un bloque (sin la separación inferior). -fn alto_contenido(b: &Bloque, config: &ExportConfig) -> f64 { +fn alto_contenido(b: &Bloque, config: &ExportConfig, plantilla: &Plantilla) -> f64 { match b.tipo { TipoBloque::Regla => REGLA_ALTO, TipoBloque::Imagen => IMAGEN_ALTO, TipoBloque::Salto => 0.0, TipoBloque::Tabla => { let tamano = config.tamano_cuerpo * 0.9; - let alto_linea = tamano * INTERLINEA; + let alto_linea = tamano * plantilla.interlinea; let mut alto = 2.0 * PADDING_CAJA; for fila in &b.tabla_envuelta { alto += alto_fila_tabla(fila, alto_linea); @@ -1113,11 +1291,11 @@ fn alto_contenido(b: &Bloque, config: &ExportConfig) -> f64 { alto } _ => { - let (tamano, _) = tamano_y_estilo(b.tipo, config); - let alto_linea = tamano * INTERLINEA; + let (tamano, _) = tamano_y_estilo(b.tipo, config, plantilla); + let alto_linea = tamano * plantilla.interlinea; let mut alto = b.envuelto.len() as f64 * alto_linea; if let TipoBloque::Cabecera { nivel } = b.tipo { - alto += espaciado_cabecera(nivel, config); + alto += plantilla.espaciado_cabecera(nivel, config.tamano_cuerpo); } if b.tipo == TipoBloque::Codigo { alto += PADDING_CAJA * 2.0; @@ -1128,8 +1306,14 @@ fn alto_contenido(b: &Bloque, config: &ExportConfig) -> f64 { } /// Mide un bloque (rellena `envuelto` y `alto`) con el contexto de cairo dado. -fn medir_bloque(b: &mut Bloque, config: &ExportConfig, cr: &cairo::Context, ancho_util: f64) { - let (tamano, estilo_base) = tamano_y_estilo(b.tipo, config); +fn medir_bloque( + b: &mut Bloque, + config: &ExportConfig, + plantilla: &Plantilla, + cr: &cairo::Context, + ancho_util: f64, +) { + let (tamano, estilo_base) = tamano_y_estilo(b.tipo, config, plantilla); let ancho_texto = ancho_util - sangrado_x(b, ancho_util); let mut envuelto: Vec> = Vec::new(); @@ -1153,12 +1337,12 @@ fn medir_bloque(b: &mut Bloque, config: &ExportConfig, cr: &cairo::Context, anch b.envuelto = envuelto; } - let contenido = alto_contenido(b, config); + let contenido = alto_contenido(b, config, plantilla); b.alto = contenido + if b.tipo == TipoBloque::Salto { 0.0 } else { - ESPACIO_BLOQUE + plantilla.espacio_bloque }; } @@ -1217,18 +1401,25 @@ fn sangrado_x(b: &Bloque, _ancho_util: f64) -> f64 { } } +/// Parámetros del dibujado que se mantienen entre bloques de una misma página. +#[derive(Clone, Copy)] +struct Lienzo<'a> { + config: &'a ExportConfig, + plantilla: &'a Plantilla, + paleta: &'a Paleta, + ancho: f64, +} + /// Dibuja un bloque en `(x, y_top)` y devuelve la nueva `y` tras el bloque. -fn dibujar_bloque( - cr: &cairo::Context, - b: &Bloque, - config: &ExportConfig, - paleta: &Paleta, - ancho_util: f64, - x: f64, - y_top: f64, -) -> f64 { - let (tamano, _) = tamano_y_estilo(b.tipo, config); - let alto_linea = tamano * INTERLINEA; +fn dibujar_bloque(cr: &cairo::Context, b: &Bloque, lienzo: Lienzo, x: f64, y_top: f64) -> f64 { + let Lienzo { + config, + plantilla, + paleta, + ancho: ancho_util, + } = lienzo; + let (tamano, _) = tamano_y_estilo(b.tipo, config, plantilla); + let alto_linea = tamano * plantilla.interlinea; // Fondo de la página (solo en oscuro; el claro y el mono son blancos). // Se pinta una vez por página, no aquí. @@ -1266,13 +1457,13 @@ fn dibujar_bloque( } TipoBloque::Salto => {} TipoBloque::Tabla => { - dibujar_tabla(cr, b, config, paleta, ancho_util, x, y_top); + dibujar_tabla(cr, b, lienzo, x, y_top); } TipoBloque::Codigo | TipoBloque::Cita | TipoBloque::Cabecera { .. } | TipoBloque::Parrafo => { - let contenido = alto_contenido(b, config); + let contenido = alto_contenido(b, config, plantilla); // Color base del texto según el tipo de bloque. let color_texto = match b.tipo { TipoBloque::Cabecera { .. } => paleta.cabecera, @@ -1306,7 +1497,7 @@ fn dibujar_bloque( cursor_y += PADDING_CAJA; } if let TipoBloque::Cabecera { nivel } = b.tipo { - cursor_y += espaciado_cabecera(nivel, config) / 2.0; + cursor_y += plantilla.espaciado_cabecera(nivel, config.tamano_cuerpo) / 2.0; } let font_ext = { @@ -1359,18 +1550,16 @@ fn dibujar_bloque( /// Dibuja una tabla con columnas reales: caja de fondo, franja de cabecera en /// negrita con filete fuerte, rejilla ligera entre filas y columnas, y texto /// de celda alineado y envuelto dentro de su columna. -fn dibujar_tabla( - cr: &cairo::Context, - b: &Bloque, - config: &ExportConfig, - paleta: &Paleta, - ancho_util: f64, - x: f64, - y_top: f64, -) { +fn dibujar_tabla(cr: &cairo::Context, b: &Bloque, lienzo: Lienzo, x: f64, y_top: f64) { + let Lienzo { + config, + plantilla, + paleta, + ancho: ancho_util, + } = lienzo; let tamano = config.tamano_cuerpo * 0.9; - let alto_linea = tamano * INTERLINEA; - let contenido = alto_contenido(b, config); + let alto_linea = tamano * plantilla.interlinea; + let contenido = alto_contenido(b, config, plantilla); // Caja de fondo completa. cr.set_source_rgb( @@ -1537,20 +1726,95 @@ fn dibujar_palabra( /// Construye una [`gtk4::PrintOperation`] lista para exportar `texto`. /// /// `begin_print` mide los bloques con cairo y planifica las páginas; -/// `draw_page` pinta la página ya planificada. `sistema_oscuro` resuelve el -/// esquema `system`. +/// Bloque de título que encabeza la primera página de las plantillas con +/// portada. Se mide y pagina como una cabecera de nivel 1. +fn bloque_titulo(titulo: &str) -> Bloque { + Bloque { + tipo: TipoBloque::Cabecera { nivel: 1 }, + frases: vec![Frase { + texto: titulo.to_string(), + estilo: Estilo::default(), + }], + ..Default::default() + } +} + +/// Texto de una banda de cabecera o pie para la página `pagina` (0-based). +fn texto_adorno(adorno: Adorno, titulo: &str, pagina: usize, total: usize) -> Option { + match adorno { + Adorno::Nada => None, + Adorno::Titulo => Some(titulo.to_string()), + Adorno::NumeroPagina => Some(format!("{} / {}", pagina + 1, total)), + } +} + +/// Dibuja una línea de adorno centrada en el ancho útil. +fn dibujar_adorno( + cr: &cairo::Context, + config: &ExportConfig, + paleta: &Paleta, + texto: &str, + ancho: f64, + baseline: f64, +) { + cr.select_font_face( + familia_cuerpo(config.familia_cuerpo), + cairo::FontSlant::Normal, + cairo::FontWeight::Normal, + ); + cr.set_font_size(TAMANO_ADORNO); + cr.set_source_rgb(paleta.atenuado.0, paleta.atenuado.1, paleta.atenuado.2); + let recortado = recortar_a_ancho(cr, texto, ancho); + if let Ok(ext) = cr.text_extents(&recortado) { + cr.move_to(((ancho - ext.x_advance()) / 2.0).max(0.0), baseline); + let _ = cr.show_text(&recortado); + } +} + +/// Recorta el texto con puntos suspensivos hasta que quepa en `ancho`. +fn recortar_a_ancho(cr: &cairo::Context, texto: &str, ancho: f64) -> String { + let cabe = |t: &str| { + cr.text_extents(t) + .map(|e| e.x_advance() <= ancho) + .unwrap_or(true) + }; + if cabe(texto) { + return texto.to_string(); + } + let mut recortado: String = texto.to_string(); + while !recortado.is_empty() { + recortado.pop(); + let prueba = format!("{}…", recortado.trim_end()); + if cabe(&prueba) { + return prueba; + } + } + String::new() +} + +/// Prepara la operación de impresión. `draw_page` pinta la página ya +/// planificada. `sistema_oscuro` resuelve el esquema `system`. pub fn crear_operacion( config: &ExportConfig, texto: &str, titulo: &str, sistema_oscuro: bool, ) -> gtk4::PrintOperation { + let plantilla = Plantilla::desde(config); + let config = config.con_plantilla(&plantilla); + let op = gtk4::PrintOperation::new(); op.set_job_name(titulo); let setup = gtk4::PageSetup::new(); let papel = gtk4::PaperSize::new(Some(config.papel.paper_name())); setup.set_paper_size_and_default_margins(&papel); + if let Some(m) = plantilla.margenes { + setup.set_top_margin(m.superior, gtk4::Unit::Points); + setup.set_bottom_margin(m.inferior, gtk4::Unit::Points); + setup.set_left_margin(m.izquierda, gtk4::Unit::Points); + setup.set_right_margin(m.derecha, gtk4::Unit::Points); + } setup.set_orientation(match config.orientacion { ExportOrientation::Portrait => gtk4::PageOrientation::Portrait, ExportOrientation::Landscape => gtk4::PageOrientation::Landscape, @@ -1558,11 +1822,16 @@ pub fn crear_operacion( op.set_default_page_setup(Some(&setup)); let analisis = analyze(texto); - let bloques: Rc>> = - Rc::new(RefCell::new(construir_bloques(texto, &analisis))); + let mut iniciales = construir_bloques(texto, &analisis); + if plantilla.bloque_titulo && !titulo.trim().is_empty() { + iniciales.insert(0, bloque_titulo(titulo)); + } + let bloques: Rc>> = Rc::new(RefCell::new(iniciales)); let paginas: Rc>> = Rc::new(RefCell::new(Vec::new())); let paleta = resolver_paleta(config.esquema, sistema_oscuro); - let config = config.clone(); + let banda_sup = plantilla.banda_superior(); + let banda_inf = plantilla.banda_inferior(); + let titulo_adorno = titulo.to_string(); { let bloques = bloques.clone(); @@ -1571,13 +1840,13 @@ pub fn crear_operacion( op.connect_begin_print(move |op, ctx| { let cr = ctx.cairo_context(); let ancho = ctx.width(); - let alto = ctx.height(); + let alto_util = (ctx.height() - banda_sup - banda_inf).max(1.0); { let mut bloques = bloques.borrow_mut(); for b in bloques.iter_mut() { - medir_bloque(b, &config, &cr, ancho); + medir_bloque(b, &config, &plantilla, &cr, ancho); } - *paginas.borrow_mut() = planificar_paginas(&bloques, alto); + *paginas.borrow_mut() = planificar_paginas(&bloques, alto_util); } op.set_n_pages(paginas.borrow().len() as i32); }); @@ -1590,11 +1859,11 @@ pub fn crear_operacion( op.connect_draw_page(move |_op, ctx, nr| { let cr = ctx.cairo_context(); let ancho = ctx.width(); - let alto = ctx.height(); + let alto_pagina = ctx.height(); // Fondo de la página. cr.set_source_rgb(paleta.fondo.0, paleta.fondo.1, paleta.fondo.2); - cr.rectangle(0.0, 0.0, ancho, alto); + cr.rectangle(0.0, 0.0, ancho, alto_pagina); let _ = cr.fill(); let paginas = paginas.borrow(); @@ -1602,18 +1871,70 @@ pub fn crear_operacion( let Some(pagina) = paginas.get(nr as usize) else { return; }; - let mut y = 0.0; + let total = paginas.len(); + let mut y = banda_sup; + let lienzo = Lienzo { + config: &config, + plantilla: &plantilla, + paleta: &paleta, + ancho, + }; for &idx in &pagina.bloques { if let Some(b) = bloques.get(idx) { - y = dibujar_bloque(&cr, b, &config, &paleta, ancho, 0.0, y); + y = dibujar_bloque(&cr, b, lienzo, 0.0, y); } } + + // Cabecera: en la primera página se omite si la portada ya lleva + // el título. + if !(nr == 0 && plantilla.bloque_titulo) { + if let Some(t) = + texto_adorno(plantilla.cabecera, &titulo_adorno, nr as usize, total) + { + dibujar_adorno( + &cr, + &config, + &paleta, + &t, + ancho, + (banda_sup + TAMANO_ADORNO) / 2.0, + ); + } + } + if let Some(t) = texto_adorno(plantilla.pie, &titulo_adorno, nr as usize, total) { + dibujar_adorno( + &cr, + &config, + &paleta, + &t, + ancho, + alto_pagina - banda_inf / 2.0 + TAMANO_ADORNO / 2.0, + ); + } }); } op } +/// Lanza la operación con la acción pedida y avisa si falla. +fn ejecutar( + parent: &impl IsA, + op: >k4::PrintOperation, + accion: gtk4::PrintOperationAction, + que: &str, +) { + match op.run(accion, Some(parent)) { + Ok(gtk4::PrintOperationResult::Error) => { + if let Some(e) = op.error() { + glib::g_warning!("scribe", "no se pudo {que}: {e}"); + } + } + Ok(_) => {} + Err(e) => glib::g_warning!("scribe", "no se pudo {que}: {e}"), + } +} + /// Abre el diálogo de impresión/exportación a PDF para el documento. pub fn exportar( parent: &impl IsA, @@ -1623,15 +1944,29 @@ pub fn exportar( sistema_oscuro: bool, ) { let op = crear_operacion(config, texto, titulo, sistema_oscuro); - match op.run(gtk4::PrintOperationAction::PrintDialog, Some(parent)) { - Ok(gtk4::PrintOperationResult::Error) => { - if let Some(e) = op.error() { - glib::g_warning!("scribe", "no se pudo exportar a PDF: {e}"); - } - } - Ok(_) => {} - Err(e) => glib::g_warning!("scribe", "no se pudo exportar a PDF: {e}"), - } + ejecutar( + parent, + &op, + gtk4::PrintOperationAction::PrintDialog, + "exportar a PDF", + ); +} + +/// Abre la previsualización de impresión con la misma maqueta que el PDF. +pub fn previsualizar( + parent: &impl IsA, + config: &ExportConfig, + texto: &str, + titulo: &str, + sistema_oscuro: bool, +) { + let op = crear_operacion(config, texto, titulo, sistema_oscuro); + ejecutar( + parent, + &op, + gtk4::PrintOperationAction::Preview, + "previsualizar la impresión", + ); } /// Exporta directamente a `ruta` (sin diálogo). La usan los tests de @@ -1867,4 +2202,111 @@ mod tests { vec![AlineacionCelda::Izquierda, AlineacionCelda::Derecha] ); } + + fn config_de(template: ExportTemplate) -> ExportConfig { + ExportConfig { + esquema: ExportColorScheme::Light, + familia_cuerpo: FontFamily::Sans, + tamano_cuerpo: 11.0, + fuente_mono: ExportMonoFont::AdwaitaMono, + papel: ExportPageSize::A4, + orientacion: ExportOrientation::Portrait, + template, + } + } + + #[test] + fn pdf_custom_no_impone_maquetacion_y_conserva_los_ajustes() { + let config = config_de(ExportTemplate::Custom); + let plantilla = Plantilla::desde(&config); + assert!(plantilla.margenes.is_none()); + assert_eq!(plantilla.interlinea, INTERLINEA); + assert_eq!(plantilla.espacio_bloque, ESPACIO_BLOQUE); + assert_eq!(plantilla.cabecera, Adorno::Nada); + assert_eq!(plantilla.pie, Adorno::Nada); + assert!(!plantilla.bloque_titulo); + let efectiva = config.con_plantilla(&plantilla); + assert_eq!(efectiva.tamano_cuerpo, 11.0); + assert_eq!(efectiva.familia_cuerpo, FontFamily::Sans); + } + + #[test] + fn pdf_article_reproduce_los_valores_de_siempre() { + let config = config_de(ExportTemplate::Article); + let plantilla = Plantilla::desde(&config); + assert!(plantilla.margenes.is_none()); + assert_eq!(plantilla.interlinea, INTERLINEA); + assert_eq!(plantilla.espacio_bloque, ESPACIO_BLOQUE); + for (nivel, esperado) in [(1, 1.9), (2, 1.5), (3, 1.25), (4, 1.1)] { + assert_eq!(plantilla.escala_cabecera(nivel), esperado); + } + assert_eq!(plantilla.banda_superior(), 0.0); + assert_eq!(plantilla.banda_inferior(), 0.0); + assert!(!plantilla.bloque_titulo); + } + + #[test] + fn pdf_manuscript_fija_margenes_interlineado_y_adornos() { + let config = config_de(ExportTemplate::Manuscript); + let plantilla = Plantilla::desde(&config); + let margenes = plantilla.margenes.expect("manuscrito fija márgenes"); + assert_eq!(margenes, Margenes::simetricos(72.0)); + assert_eq!(plantilla.interlinea, 2.0); + assert_eq!(plantilla.cabecera, Adorno::Titulo); + assert_eq!(plantilla.pie, Adorno::NumeroPagina); + assert!(plantilla.bloque_titulo); + assert_eq!(plantilla.banda_superior(), BANDA_ADORNO); + assert_eq!(plantilla.banda_inferior(), BANDA_ADORNO); + let efectiva = config.con_plantilla(&plantilla); + assert_eq!(efectiva.familia_cuerpo, FontFamily::Serif); + assert_eq!(efectiva.tamano_cuerpo, 12.0); + } + + #[test] + fn pdf_compact_aprieta_el_espaciado_y_no_lleva_portada() { + let config = config_de(ExportTemplate::Compact); + let plantilla = Plantilla::desde(&config); + assert_eq!(plantilla.margenes, Some(Margenes::simetricos(36.0))); + assert_eq!(plantilla.interlinea, 1.25); + assert_eq!(plantilla.espacio_bloque, 7.0); + assert!(!plantilla.bloque_titulo); + assert_eq!(plantilla.pie, Adorno::NumeroPagina); + } + + #[test] + fn pdf_letter_usa_margenes_ajustados_y_no_lleva_portada() { + let config = config_de(ExportTemplate::Letter); + let plantilla = Plantilla::desde(&config); + assert_eq!(plantilla.margenes, Some(Margenes::simetricos(54.0))); + assert!(!plantilla.bloque_titulo); + assert_eq!(plantilla.cabecera, Adorno::Nada); + } + + #[test] + fn pdf_el_bloque_de_titulo_es_una_cabecera_de_nivel_uno() { + let bloque = bloque_titulo("Mi documento"); + assert_eq!(bloque.tipo, TipoBloque::Cabecera { nivel: 1 }); + assert_eq!(bloque.frases.len(), 1); + assert_eq!(bloque.frases[0].texto, "Mi documento"); + } + + #[test] + fn pdf_el_pie_numera_con_el_total_de_paginas() { + assert_eq!( + texto_adorno(Adorno::NumeroPagina, "Título", 0, 3), + Some("1 / 3".to_string()) + ); + assert_eq!( + texto_adorno(Adorno::Titulo, "Título", 4, 9), + Some("Título".to_string()) + ); + assert_eq!(texto_adorno(Adorno::Nada, "Título", 0, 1), None); + } + + #[test] + fn pdf_la_escala_de_cabecera_desconocida_no_crece() { + let plantilla = Plantilla::desde(&config_de(ExportTemplate::Article)); + assert_eq!(plantilla.escala_cabecera(0), 1.0); + assert_eq!(plantilla.escala_cabecera(9), 1.0); + } } diff --git a/src/settings.rs b/src/settings.rs index eced231..3b3f6c4 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -285,6 +285,56 @@ impl ExportOrientation { } } +/// Plantilla de página del PDF exportado. `Custom` usa los ajustes +/// granulares; el resto fija sus propios valores de maquetación. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ExportTemplate { + Custom, + Article, + Manuscript, + Letter, + Compact, +} + +impl ExportTemplate { + fn from_nick(nick: &str) -> Self { + match nick { + "article" => Self::Article, + "manuscript" => Self::Manuscript, + "letter" => Self::Letter, + "compact" => Self::Compact, + _ => Self::Custom, + } + } + pub fn nick(self) -> &'static str { + match self { + Self::Custom => "custom", + Self::Article => "article", + Self::Manuscript => "manuscript", + Self::Letter => "letter", + Self::Compact => "compact", + } + } + pub fn index(self) -> u32 { + match self { + Self::Custom => 0, + Self::Article => 1, + Self::Manuscript => 2, + Self::Letter => 3, + Self::Compact => 4, + } + } + pub fn from_index(i: u32) -> Self { + match i { + 1 => Self::Article, + 2 => Self::Manuscript, + 3 => Self::Letter, + 4 => Self::Compact, + _ => Self::Custom, + } + } +} + pub struct AppSettings { settings: Option, } @@ -601,4 +651,17 @@ impl AppSettings { } }); } + + pub fn export_template(&self) -> ExportTemplate { + ExportTemplate::from_nick(&self.get("custom".to_string(), |s| { + s.string("export-template").to_string() + })) + } + pub fn set_export_template(&self, v: ExportTemplate) { + self.set(|s| { + if let Err(e) = s.set_string("export-template", v.nick()) { + log_set_error("export-template", e); + } + }); + } } diff --git a/tests/export_pdf.rs b/tests/export_pdf.rs index 69bc17b..d8b30ef 100644 --- a/tests/export_pdf.rs +++ b/tests/export_pdf.rs @@ -76,6 +76,7 @@ fn exporta_un_pdf_real_de_prueba() { fuente_mono: settings::ExportMonoFont::AdwaitaMono, papel: settings::ExportPageSize::A4, orientacion: settings::ExportOrientation::Portrait, + template: settings::ExportTemplate::Custom, }; export_pdf::exportar_a_fichero(&config, DOC, "Prueba", &ruta, false).expect("exportar a PDF"); From d20e6fc2aaf24da5bca37010a284b7f33ac4bb29 Mon Sep 17 00:00:00 2001 From: gnacho Date: Tue, 27 Oct 2026 05:56:36 +0100 Subject: [PATCH 2/3] feat(export): select templates in preferences and preview the result --- po/es.po | 330 +++++++++++++++++++++++++-------------------- po/scribe.pot | 318 +++++++++++++++++++++++-------------------- src/preferences.rs | 39 +++++- src/window.rs | 32 +++-- 4 files changed, 411 insertions(+), 308 deletions(-) diff --git a/po/es.po b/po/es.po index f7582ad..c581461 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 05:50+0100\n" "PO-Revision-Date: 2026-09-19 19:58+0200\n" "Last-Translator: Automatically generated\n" "Language-Team: none\n" @@ -17,259 +17,265 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" -#: src/main.rs:238 +#: src/main.rs:276 msgid "Recover unsaved documents?" msgstr "¿Recuperar los documentos sin guardar?" -#: src/main.rs:241 +#: src/main.rs:279 msgid "_Recover All" msgstr "_Recuperar todo" -#: src/main.rs:242 +#: src/main.rs:280 msgid "_Discard All" msgstr "_Descartar todo" -#: src/main.rs:243 +#: src/main.rs:281 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 +#, fuzzy +#| msgid "Typewriter Mode" +msgid "Typewriter Mode (Ctrl+Shift+T)" +msgstr "Máquina de escribir" -#: 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}" @@ -282,7 +288,7 @@ msgstr "Apariencia" msgid "Theme" msgstr "Tema" -#: src/preferences.rs:58 src/preferences.rs:403 +#: src/preferences.rs:58 src/preferences.rs:422 msgid "Color scheme" msgstr "Esquema de color" @@ -290,283 +296,299 @@ msgstr "Esquema de color" 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:60 src/preferences.rs:425 msgid "System" msgstr "Sistema" -#: src/preferences.rs:60 src/preferences.rs:80 src/preferences.rs:407 +#: src/preferences.rs:60 src/preferences.rs:426 msgid "Light" msgstr "Claro" -#: src/preferences.rs:60 src/preferences.rs:81 src/preferences.rs:408 +#: src/preferences.rs:60 src/preferences.rs:427 msgid "Dark" msgstr "Oscuro" -#: src/preferences.rs:76 +#: src/preferences.rs:68 +msgid "Automatic (follows the color scheme)" +msgstr "Automático (sigue el esquema de color)" + +#: src/preferences.rs:87 #, fuzzy #| msgid "Theme" msgid "Text theme" msgstr "Tema" -#: src/preferences.rs:77 +#: src/preferences.rs:88 msgid "Semantic colors of the document, applied live" msgstr "Colores semánticos del documento, aplicados en vivo" -#: src/preferences.rs:79 -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:84 -msgid "Gruvbox Dark" -msgstr "Gruvbox oscuro" - -#: src/preferences.rs:101 +#: src/preferences.rs:108 msgid "Text" msgstr "Texto" -#: src/preferences.rs:102 +#: src/preferences.rs:109 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:113 src/preferences.rs:433 msgid "Font family" msgstr "Familia tipográfica" -#: src/preferences.rs:107 src/preferences.rs:415 +#: src/preferences.rs:114 src/preferences.rs:434 msgid "Document body" msgstr "Cuerpo del documento" -#: src/preferences.rs:109 src/preferences.rs:417 +#: src/preferences.rs:116 src/preferences.rs:436 msgid "Sans (Cantarell)" msgstr "Sans (Cantarell)" -#: src/preferences.rs:110 src/preferences.rs:418 +#: src/preferences.rs:117 src/preferences.rs:437 msgid "Serif" msgstr "Serif" -#: src/preferences.rs:111 src/preferences.rs:419 +#: src/preferences.rs:118 src/preferences.rs:438 msgid "Monospace" msgstr "Monoespaciada" -#: src/preferences.rs:116 +#: src/preferences.rs:123 msgid "Size" msgstr "Tamaño" -#: src/preferences.rs:117 +#: src/preferences.rs:124 msgid "Pixels" msgstr "Píxeles" -#: src/preferences.rs:122 +#: src/preferences.rs:129 msgid "Line spacing" msgstr "Interlineado" -#: src/preferences.rs:123 +#: src/preferences.rs:130 msgid "Multiplier on the font size" msgstr "Multiplicador sobre el tamaño de fuente" -#: src/preferences.rs:128 +#: src/preferences.rs:135 msgid "Column width" msgstr "Ancho de la columna" -#: src/preferences.rs:129 +#: src/preferences.rs:136 msgid "Maximum text width, in pixels" msgstr "Ancho máximo del texto, en píxeles" -#: src/preferences.rs:192 +#: src/preferences.rs:199 msgid "Editor" msgstr "Editor" -#: src/preferences.rs:197 +#: src/preferences.rs:204 msgid "Markup" msgstr "Marcado" -#: src/preferences.rs:199 +#: src/preferences.rs:206 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:216 src/preferences.rs:227 msgid "Markdown marks" msgstr "Marcas de Markdown" -#: src/preferences.rs:210 +#: src/preferences.rs:217 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:219 msgid "Always hide" msgstr "Ocultar siempre" -#: src/preferences.rs:213 +#: src/preferences.rs:220 msgid "Show on focus" msgstr "Mostrar al enfocar" -#: src/preferences.rs:214 src/preferences.rs:225 +#: src/preferences.rs:221 src/preferences.rs:232 msgid "Always dim" msgstr "Atenuar siempre" -#: src/preferences.rs:221 +#: src/preferences.rs:228 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" -#: src/preferences.rs:223 +#: src/preferences.rs:230 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:231 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:241 msgid "Writing" msgstr "Escritura" -#: src/preferences.rs:237 +#: src/preferences.rs:244 msgid "Continue lists" msgstr "Continuar listas" -#: src/preferences.rs:239 +#: src/preferences.rs:246 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" -#: src/preferences.rs:244 +#: src/preferences.rs:251 msgid "Focus mode" msgstr "Modo foco" -#: src/preferences.rs:245 +#: src/preferences.rs:252 msgid "Dims everything except the current paragraph" msgstr "Atenúa todo salvo el párrafo actual" -#: src/preferences.rs:249 +#: src/preferences.rs:256 msgid "Typewriter mode" msgstr "Máquina de escribir" -#: src/preferences.rs:250 +#: src/preferences.rs:257 msgid "Keeps the cursor line vertically centered" msgstr "Mantiene la línea del cursor centrada verticalmente" -#: src/preferences.rs:254 +#: src/preferences.rs:261 msgid "Spell checking" msgstr "Corrector ortográfico" -#: src/preferences.rs:256 +#: src/preferences.rs:263 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:268 msgid "Tab width" msgstr "Ancho de tabulación" -#: src/preferences.rs:262 +#: src/preferences.rs:269 msgid "Spaces per indent level" msgstr "Espacios por nivel de sangría" -#: src/preferences.rs:273 +#: src/preferences.rs:280 msgid "Saving" msgstr "Guardado" -#: src/preferences.rs:276 +#: src/preferences.rs:283 msgid "Autosave" msgstr "Guardado automático" -#: src/preferences.rs:277 +#: src/preferences.rs:284 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:288 msgid "Interval" msgstr "Intervalo" -#: src/preferences.rs:282 +#: src/preferences.rs:289 msgid "Seconds between saves" msgstr "Segundos entre guardados" -#: src/preferences.rs:299 +#: src/preferences.rs:306 msgid "History" msgstr "Historial" -#: src/preferences.rs:302 +#: src/preferences.rs:309 msgid "Clear" msgstr "Vaciar" -#: src/preferences.rs:307 +#: src/preferences.rs:314 msgid "Recent documents" msgstr "Documentos recientes" -#: src/preferences.rs:308 +#: src/preferences.rs:315 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:398 msgid "Export" msgstr "Exportar" -#: src/preferences.rs:396 +#: src/preferences.rs:403 msgid "PDF" msgstr "PDF" -#: src/preferences.rs:398 +#: src/preferences.rs:405 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:410 +msgid "Template" +msgstr "Plantilla" + +#: src/preferences.rs:411 +msgid "Page layout preset; Custom uses the settings below" +msgstr "Ajuste de página predefinido; Personalizada usa los ajustes de abajo" + +#: src/preferences.rs:413 +msgid "Custom" +msgstr "Personalizada" + +#: src/preferences.rs:414 +msgid "Article" +msgstr "Artículo" + +#: src/preferences.rs:415 +msgid "Manuscript" +msgstr "Manuscrito" + +#: src/preferences.rs:416 +msgid "Letter" +msgstr "Carta" + +#: src/preferences.rs:417 +msgid "Compact" +msgstr "Compacta" + +#: src/preferences.rs:423 msgid "Monochrome prints everything in black on white" msgstr "Monocromo imprime todo en negro sobre blanco" -#: src/preferences.rs:409 +#: src/preferences.rs:428 msgid "Monochrome" msgstr "Monocromo" -#: src/preferences.rs:424 +#: src/preferences.rs:443 msgid "Font size" msgstr "Tamaño de fuente" -#: src/preferences.rs:425 +#: src/preferences.rs:444 msgid "Points" msgstr "Puntos" -#: src/preferences.rs:430 +#: src/preferences.rs:449 msgid "Monospace font" msgstr "Fuente monoespaciada" -#: src/preferences.rs:431 +#: src/preferences.rs:450 msgid "Used for code blocks and tables" msgstr "Se usa en bloques de código y tablas" -#: src/preferences.rs:440 +#: src/preferences.rs:459 msgid "Page size" msgstr "Tamaño de página" -#: src/preferences.rs:441 +#: src/preferences.rs:460 msgid "Paper" msgstr "Papel" -#: src/preferences.rs:446 +#: src/preferences.rs:465 msgid "Orientation" msgstr "Orientación" -#: src/preferences.rs:447 +#: src/preferences.rs:466 msgid "Paper orientation" msgstr "Orientación del papel" -#: src/preferences.rs:448 +#: src/preferences.rs:467 msgid "Portrait" msgstr "Vertical" -#: src/preferences.rs:448 +#: src/preferences.rs:467 msgid "Landscape" msgstr "Horizontal" @@ -674,6 +696,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 +708,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" diff --git a/po/scribe.pot b/po/scribe.pot index b8263f1..5450e75 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 05:50+0100\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -17,259 +17,263 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" -#: src/main.rs:238 +#: src/main.rs:276 msgid "Recover unsaved documents?" msgstr "" -#: src/main.rs:241 +#: src/main.rs:279 msgid "_Recover All" msgstr "" -#: src/main.rs:242 +#: src/main.rs:280 msgid "_Discard All" msgstr "" -#: src/main.rs:243 +#: src/main.rs:281 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 "" @@ -282,7 +286,7 @@ msgstr "" msgid "Theme" msgstr "" -#: src/preferences.rs:58 src/preferences.rs:403 +#: src/preferences.rs:58 src/preferences.rs:422 msgid "Color scheme" msgstr "" @@ -290,275 +294,291 @@ msgstr "" msgid "Follows the system unless you force one" msgstr "" -#: src/preferences.rs:60 src/preferences.rs:406 +#: src/preferences.rs:60 src/preferences.rs:425 msgid "System" msgstr "" -#: src/preferences.rs:60 src/preferences.rs:80 src/preferences.rs:407 +#: src/preferences.rs:60 src/preferences.rs:426 msgid "Light" msgstr "" -#: src/preferences.rs:60 src/preferences.rs:81 src/preferences.rs:408 +#: src/preferences.rs:60 src/preferences.rs:427 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:68 msgid "Automatic (follows the color scheme)" msgstr "" -#: src/preferences.rs:82 -msgid "Sepia" -msgstr "" - -#: src/preferences.rs:83 -msgid "Solarized Dark" +#: src/preferences.rs:87 +msgid "Text theme" msgstr "" -#: src/preferences.rs:84 -msgid "Gruvbox Dark" +#: src/preferences.rs:88 +msgid "Semantic colors of the document, applied live" msgstr "" -#: src/preferences.rs:101 +#: src/preferences.rs:108 msgid "Text" msgstr "" -#: src/preferences.rs:102 +#: src/preferences.rs:109 msgid "Code and tables always use a monospaced font" msgstr "" -#: src/preferences.rs:106 src/preferences.rs:414 +#: src/preferences.rs:113 src/preferences.rs:433 msgid "Font family" msgstr "" -#: src/preferences.rs:107 src/preferences.rs:415 +#: src/preferences.rs:114 src/preferences.rs:434 msgid "Document body" msgstr "" -#: src/preferences.rs:109 src/preferences.rs:417 +#: src/preferences.rs:116 src/preferences.rs:436 msgid "Sans (Cantarell)" msgstr "" -#: src/preferences.rs:110 src/preferences.rs:418 +#: src/preferences.rs:117 src/preferences.rs:437 msgid "Serif" msgstr "" -#: src/preferences.rs:111 src/preferences.rs:419 +#: src/preferences.rs:118 src/preferences.rs:438 msgid "Monospace" msgstr "" -#: src/preferences.rs:116 +#: src/preferences.rs:123 msgid "Size" msgstr "" -#: src/preferences.rs:117 +#: src/preferences.rs:124 msgid "Pixels" msgstr "" -#: src/preferences.rs:122 +#: src/preferences.rs:129 msgid "Line spacing" msgstr "" -#: src/preferences.rs:123 +#: src/preferences.rs:130 msgid "Multiplier on the font size" msgstr "" -#: src/preferences.rs:128 +#: src/preferences.rs:135 msgid "Column width" msgstr "" -#: src/preferences.rs:129 +#: src/preferences.rs:136 msgid "Maximum text width, in pixels" msgstr "" -#: src/preferences.rs:192 +#: src/preferences.rs:199 msgid "Editor" msgstr "" -#: src/preferences.rs:197 +#: src/preferences.rs:204 msgid "Markup" msgstr "" -#: src/preferences.rs:199 +#: src/preferences.rs:206 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:216 src/preferences.rs:227 msgid "Markdown marks" msgstr "" -#: src/preferences.rs:210 +#: src/preferences.rs:217 msgid "“On focus” reveals them only on the cursor line" msgstr "" -#: src/preferences.rs:212 +#: src/preferences.rs:219 msgid "Always hide" msgstr "" -#: src/preferences.rs:213 +#: src/preferences.rs:220 msgid "Show on focus" msgstr "" -#: src/preferences.rs:214 src/preferences.rs:225 +#: src/preferences.rs:221 src/preferences.rs:232 msgid "Always dim" msgstr "" -#: src/preferences.rs:221 +#: src/preferences.rs:228 msgid "Hiding text still crashes this GTK: the marks shrink instead" msgstr "" -#: src/preferences.rs:223 +#: src/preferences.rs:230 msgid "Always hide (shrinks the marks; GTK cannot hide them yet)" msgstr "" -#: src/preferences.rs:224 +#: src/preferences.rs:231 msgid "Show on focus (shrinks the marks; GTK cannot hide them yet)" msgstr "" -#: src/preferences.rs:234 +#: src/preferences.rs:241 msgid "Writing" msgstr "" -#: src/preferences.rs:237 +#: src/preferences.rs:244 msgid "Continue lists" msgstr "" -#: src/preferences.rs:239 +#: src/preferences.rs:246 msgid "Enter repeats the dash or number; on an empty item, it closes the list" msgstr "" -#: src/preferences.rs:244 +#: src/preferences.rs:251 msgid "Focus mode" msgstr "" -#: src/preferences.rs:245 +#: src/preferences.rs:252 msgid "Dims everything except the current paragraph" msgstr "" -#: src/preferences.rs:249 +#: src/preferences.rs:256 msgid "Typewriter mode" msgstr "" -#: src/preferences.rs:250 +#: src/preferences.rs:257 msgid "Keeps the cursor line vertically centered" msgstr "" -#: src/preferences.rs:254 +#: src/preferences.rs:261 msgid "Spell checking" msgstr "" -#: src/preferences.rs:256 +#: src/preferences.rs:263 msgid "Underlines misspelt words; right-click offers fixes" msgstr "" -#: src/preferences.rs:261 +#: src/preferences.rs:268 msgid "Tab width" msgstr "" -#: src/preferences.rs:262 +#: src/preferences.rs:269 msgid "Spaces per indent level" msgstr "" -#: src/preferences.rs:273 +#: src/preferences.rs:280 msgid "Saving" msgstr "" -#: src/preferences.rs:276 +#: src/preferences.rs:283 msgid "Autosave" msgstr "" -#: src/preferences.rs:277 +#: src/preferences.rs:284 msgid "Only when the document already has a file" msgstr "" -#: src/preferences.rs:281 +#: src/preferences.rs:288 msgid "Interval" msgstr "" -#: src/preferences.rs:282 +#: src/preferences.rs:289 msgid "Seconds between saves" msgstr "" -#: src/preferences.rs:299 +#: src/preferences.rs:306 msgid "History" msgstr "" -#: src/preferences.rs:302 +#: src/preferences.rs:309 msgid "Clear" msgstr "" -#: src/preferences.rs:307 +#: src/preferences.rs:314 msgid "Recent documents" msgstr "" -#: src/preferences.rs:308 +#: src/preferences.rs:315 msgid "Clears the list shown in the sidebar" msgstr "" -#: src/preferences.rs:391 +#: src/preferences.rs:398 msgid "Export" msgstr "" -#: src/preferences.rs:396 +#: src/preferences.rs:403 msgid "PDF" msgstr "" -#: src/preferences.rs:398 +#: src/preferences.rs:405 msgid "These settings apply to PDF export only, not to the editor view" msgstr "" -#: src/preferences.rs:404 +#: src/preferences.rs:410 +msgid "Template" +msgstr "" + +#: src/preferences.rs:411 +msgid "Page layout preset; Custom uses the settings below" +msgstr "" + +#: src/preferences.rs:413 +msgid "Custom" +msgstr "" + +#: src/preferences.rs:414 +msgid "Article" +msgstr "" + +#: src/preferences.rs:415 +msgid "Manuscript" +msgstr "" + +#: src/preferences.rs:416 +msgid "Letter" +msgstr "" + +#: src/preferences.rs:417 +msgid "Compact" +msgstr "" + +#: src/preferences.rs:423 msgid "Monochrome prints everything in black on white" msgstr "" -#: src/preferences.rs:409 +#: src/preferences.rs:428 msgid "Monochrome" msgstr "" -#: src/preferences.rs:424 +#: src/preferences.rs:443 msgid "Font size" msgstr "" -#: src/preferences.rs:425 +#: src/preferences.rs:444 msgid "Points" msgstr "" -#: src/preferences.rs:430 +#: src/preferences.rs:449 msgid "Monospace font" msgstr "" -#: src/preferences.rs:431 +#: src/preferences.rs:450 msgid "Used for code blocks and tables" msgstr "" -#: src/preferences.rs:440 +#: src/preferences.rs:459 msgid "Page size" msgstr "" -#: src/preferences.rs:441 +#: src/preferences.rs:460 msgid "Paper" msgstr "" -#: src/preferences.rs:446 +#: src/preferences.rs:465 msgid "Orientation" msgstr "" -#: src/preferences.rs:447 +#: src/preferences.rs:466 msgid "Paper orientation" msgstr "" -#: src/preferences.rs:448 +#: src/preferences.rs:467 msgid "Portrait" msgstr "" -#: src/preferences.rs:448 +#: src/preferences.rs:467 msgid "Landscape" msgstr "" @@ -666,6 +686,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/preferences.rs b/src/preferences.rs index 92c1c3e..6284131 100644 --- a/src/preferences.rs +++ b/src/preferences.rs @@ -9,7 +9,7 @@ use std::rc::Rc; use crate::settings::{ gtk_hides_invisible_safely, AppSettings, ExportColorScheme, ExportMonoFont, ExportOrientation, - ExportPageSize, FontFamily, MarkupVisibility, + ExportPageSize, ExportTemplate, FontFamily, MarkupVisibility, }; fn spin(title: &str, subtitle: &str, adj: >k4::Adjustment, digits: u32) -> adw::SpinRow { @@ -406,6 +406,18 @@ fn export_page(settings: &Rc, apply: &Rc) -> adw::Prefere )) .build(); + let template = combo_i18n( + &gettext("Template"), + &gettext("Page layout preset; Custom uses the settings below"), + &[ + gettext("Custom"), + gettext("Article"), + gettext("Manuscript"), + gettext("Letter"), + gettext("Compact"), + ], + settings.export_template().index(), + ); let scheme = combo_i18n( &gettext("Color scheme"), &gettext("Monochrome prints everything in black on white"), @@ -456,12 +468,37 @@ fn export_page(settings: &Rc, apply: &Rc) -> adw::Prefere settings.export_orientation().index(), ); + // Los ajustes granulares solo mandan con la plantilla Custom. + let granulares: Vec = vec![ + scheme.clone().upcast(), + family.clone().upcast(), + size.clone().upcast(), + mono.clone().upcast(), + ]; + for row in &granulares { + row.set_sensitive(settings.export_template() == ExportTemplate::Custom); + } + + group.add(&template); for row in [&scheme, &family, &mono, &page_size, &orientation] { group.add(row); } group.add(&size); page.add(&group); + { + let settings = settings.clone(); + let apply = apply.clone(); + let granulares = granulares.clone(); + template.connect_selected_notify(move |row| { + let plantilla = ExportTemplate::from_index(row.selected()); + settings.set_export_template(plantilla); + for fila in &granulares { + fila.set_sensitive(plantilla == ExportTemplate::Custom); + } + apply(); + }); + } { let settings = settings.clone(); let apply = apply.clone(); diff --git a/src/window.rs b/src/window.rs index b2ccaa1..afa5cdd 100644 --- a/src/window.rs +++ b/src/window.rs @@ -818,6 +818,7 @@ impl ScribeWindow { file_section.append(Some(&gettext("_Save")), Some("win.save")); file_section.append(Some(&gettext("Save _As…")), Some("win.save-as")); file_section.append(Some(&gettext("Export to _PDF…")), Some("win.export-pdf")); + file_section.append(Some(&gettext("Print Pre_view…")), Some("win.preview-pdf")); menu.append_section(None, &file_section); let view_section = gio::Menu::new(); @@ -1593,6 +1594,7 @@ impl ScribeWindow { let action_tab_prev = gio::SimpleAction::new("tab-prev", None); let action_reload = gio::SimpleAction::new("reload", None); let action_export_pdf = gio::SimpleAction::new("export-pdf", None); + let action_preview_pdf = gio::SimpleAction::new("preview-pdf", None); let action_find = gio::SimpleAction::new("find", None); let action_find_next = gio::SimpleAction::new("find-next", None); let action_find_prev = gio::SimpleAction::new("find-prev", None); @@ -1647,6 +1649,7 @@ impl ScribeWindow { &action_tab_prev, &action_reload, &action_export_pdf, + &action_preview_pdf, &action_find, &action_find_next, &action_find_prev, @@ -1736,12 +1739,12 @@ impl ScribeWindow { }); } - // ---- exportar a PDF ---- - { + // ---- exportar a PDF y previsualizar ---- + let lanzar_pdf: Rc = { let active_document = active_document.clone(); let window = window.downgrade(); let settings = settings.clone(); - action_export_pdf.connect_activate(move |_, _| { + Rc::new(move |previsualizar: bool| { let Some(window) = window.upgrade() else { return; }; @@ -1751,14 +1754,21 @@ impl ScribeWindow { let config = export_pdf::ExportConfig::desde_settings(&settings); let texto = doc.editor.text(); let titulo = doc.display_name.borrow().clone(); - export_pdf::exportar( - &window, - &config, - &texto, - &titulo, - adw::StyleManager::default().is_dark(), - ); - }); + let oscuro = adw::StyleManager::default().is_dark(); + if previsualizar { + export_pdf::previsualizar(&window, &config, &texto, &titulo, oscuro); + } else { + export_pdf::exportar(&window, &config, &texto, &titulo, oscuro); + } + }) + }; + { + let lanzar_pdf = lanzar_pdf.clone(); + action_export_pdf.connect_activate(move |_, _| lanzar_pdf(false)); + } + { + let lanzar_pdf = lanzar_pdf.clone(); + action_preview_pdf.connect_activate(move |_, _| lanzar_pdf(true)); } { From 0d9c1fc678e2bd031b9e4565d267ea9e2d9c2982 Mon Sep 17 00:00:00 2001 From: gnacho Date: Tue, 27 Oct 2026 06:03:12 +0100 Subject: [PATCH 3/3] feat(export): apply a user stylesheet on top of the template --- README.md | 41 ++ data/io.github.gnacho.Scribe.gschema.xml | 4 + po/es.po | 204 ++++--- po/scribe.pot | 186 +++--- src/export_css.rs | 735 +++++++++++++++++++++++ src/export_pdf.rs | 187 +++++- src/main.rs | 1 + src/preferences.rs | 77 +++ src/settings.rs | 20 + tests/export_pdf.rs | 72 +++ tests/tabs_smoke.rs | 2 + 11 files changed, 1348 insertions(+), 181 deletions(-) create mode 100644 src/export_css.rs 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 a7ee879..95b0916 100644 --- a/data/io.github.gnacho.Scribe.gschema.xml +++ b/data/io.github.gnacho.Scribe.gschema.xml @@ -163,5 +163,9 @@ '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 c581461..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-27 05:50+0100\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,19 +17,19 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" -#: src/main.rs:276 +#: src/main.rs:277 msgid "Recover unsaved documents?" msgstr "¿Recuperar los documentos sin guardar?" -#: src/main.rs:279 +#: src/main.rs:280 msgid "_Recover All" msgstr "_Recuperar todo" -#: src/main.rs:280 +#: src/main.rs:281 msgid "_Discard All" msgstr "_Descartar todo" -#: src/main.rs:281 +#: src/main.rs:282 msgid "Decide Later" msgstr "Decidir más tarde" @@ -148,10 +148,8 @@ msgid "Find in the document (Ctrl+F)" msgstr "Buscar en el documento (Ctrl+F)" #: src/window.rs:809 -#, fuzzy -#| msgid "Typewriter Mode" msgid "Typewriter Mode (Ctrl+Shift+T)" -msgstr "Máquina de escribir" +msgstr "Modo máquina de escribir (Ctrl+Mayús+T)" #: src/window.rs:819 msgid "Save _As…" @@ -280,318 +278,337 @@ msgstr "Aniadir al diccionario" 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:422 +#: 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:425 +#: src/preferences.rs:61 src/preferences.rs:440 msgid "System" msgstr "Sistema" -#: src/preferences.rs:60 src/preferences.rs:426 +#: src/preferences.rs:61 src/preferences.rs:441 msgid "Light" msgstr "Claro" -#: src/preferences.rs:60 src/preferences.rs:427 +#: src/preferences.rs:61 src/preferences.rs:442 msgid "Dark" msgstr "Oscuro" -#: src/preferences.rs:68 +#: src/preferences.rs:69 msgid "Automatic (follows the color scheme)" msgstr "Automático (sigue el esquema de color)" -#: src/preferences.rs:87 -#, fuzzy -#| msgid "Theme" +#: src/preferences.rs:88 msgid "Text theme" -msgstr "Tema" +msgstr "Tema del texto" -#: src/preferences.rs:88 +#: src/preferences.rs:89 msgid "Semantic colors of the document, applied live" msgstr "Colores semánticos del documento, aplicados en vivo" -#: src/preferences.rs:108 +#: src/preferences.rs:109 msgid "Text" msgstr "Texto" -#: src/preferences.rs:109 +#: 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:113 src/preferences.rs:433 +#: src/preferences.rs:114 src/preferences.rs:448 msgid "Font family" msgstr "Familia tipográfica" -#: src/preferences.rs:114 src/preferences.rs:434 +#: src/preferences.rs:115 src/preferences.rs:449 msgid "Document body" msgstr "Cuerpo del documento" -#: src/preferences.rs:116 src/preferences.rs:436 +#: src/preferences.rs:117 src/preferences.rs:451 msgid "Sans (Cantarell)" msgstr "Sans (Cantarell)" -#: src/preferences.rs:117 src/preferences.rs:437 +#: src/preferences.rs:118 src/preferences.rs:452 msgid "Serif" msgstr "Serif" -#: src/preferences.rs:118 src/preferences.rs:438 +#: src/preferences.rs:119 src/preferences.rs:453 msgid "Monospace" msgstr "Monoespaciada" -#: src/preferences.rs:123 +#: src/preferences.rs:124 msgid "Size" msgstr "Tamaño" -#: src/preferences.rs:124 +#: src/preferences.rs:125 msgid "Pixels" msgstr "Píxeles" -#: src/preferences.rs:129 +#: src/preferences.rs:130 msgid "Line spacing" msgstr "Interlineado" -#: src/preferences.rs:130 +#: src/preferences.rs:131 msgid "Multiplier on the font size" msgstr "Multiplicador sobre el tamaño de fuente" -#: src/preferences.rs:135 +#: src/preferences.rs:136 msgid "Column width" msgstr "Ancho de la columna" -#: src/preferences.rs:136 +#: src/preferences.rs:137 msgid "Maximum text width, in pixels" msgstr "Ancho máximo del texto, en píxeles" -#: src/preferences.rs:199 +#: src/preferences.rs:200 msgid "Editor" msgstr "Editor" -#: src/preferences.rs:204 +#: src/preferences.rs:205 msgid "Markup" msgstr "Marcado" -#: src/preferences.rs:206 +#: 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:216 src/preferences.rs:227 +#: src/preferences.rs:217 src/preferences.rs:228 msgid "Markdown marks" msgstr "Marcas de Markdown" -#: src/preferences.rs:217 +#: 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:219 +#: src/preferences.rs:220 msgid "Always hide" msgstr "Ocultar siempre" -#: src/preferences.rs:220 +#: src/preferences.rs:221 msgid "Show on focus" msgstr "Mostrar al enfocar" -#: src/preferences.rs:221 src/preferences.rs:232 +#: src/preferences.rs:222 src/preferences.rs:233 msgid "Always dim" msgstr "Atenuar siempre" -#: src/preferences.rs:228 +#: 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:230 +#: 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:231 +#: 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:241 +#: src/preferences.rs:242 msgid "Writing" msgstr "Escritura" -#: src/preferences.rs:244 +#: src/preferences.rs:245 msgid "Continue lists" msgstr "Continuar listas" -#: src/preferences.rs:246 +#: 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:251 +#: src/preferences.rs:252 msgid "Focus mode" msgstr "Modo foco" -#: src/preferences.rs:252 +#: src/preferences.rs:253 msgid "Dims everything except the current paragraph" msgstr "Atenúa todo salvo el párrafo actual" -#: src/preferences.rs:256 +#: src/preferences.rs:257 msgid "Typewriter mode" msgstr "Máquina de escribir" -#: src/preferences.rs:257 +#: src/preferences.rs:258 msgid "Keeps the cursor line vertically centered" msgstr "Mantiene la línea del cursor centrada verticalmente" -#: src/preferences.rs:261 +#: src/preferences.rs:262 msgid "Spell checking" msgstr "Corrector ortográfico" -#: src/preferences.rs:263 +#: 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:268 +#: src/preferences.rs:269 msgid "Tab width" msgstr "Ancho de tabulación" -#: src/preferences.rs:269 +#: src/preferences.rs:270 msgid "Spaces per indent level" msgstr "Espacios por nivel de sangría" -#: src/preferences.rs:280 +#: src/preferences.rs:281 msgid "Saving" msgstr "Guardado" -#: src/preferences.rs:283 +#: src/preferences.rs:284 msgid "Autosave" msgstr "Guardado automático" -#: src/preferences.rs:284 +#: 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:288 +#: src/preferences.rs:289 msgid "Interval" msgstr "Intervalo" -#: src/preferences.rs:289 +#: src/preferences.rs:290 msgid "Seconds between saves" msgstr "Segundos entre guardados" -#: src/preferences.rs:306 +#: src/preferences.rs:307 msgid "History" msgstr "Historial" -#: src/preferences.rs:309 +#: src/preferences.rs:310 msgid "Clear" msgstr "Vaciar" -#: src/preferences.rs:314 +#: src/preferences.rs:315 msgid "Recent documents" msgstr "Documentos recientes" -#: src/preferences.rs:315 +#: 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:398 +#: src/preferences.rs:401 +msgid "None" +msgstr "Ninguna" + +#: src/preferences.rs:413 msgid "Export" msgstr "Exportar" -#: src/preferences.rs:403 +#: src/preferences.rs:418 msgid "PDF" msgstr "PDF" -#: src/preferences.rs:405 +#: 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:410 +#: src/preferences.rs:425 msgid "Template" msgstr "Plantilla" -#: src/preferences.rs:411 +#: 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:413 +#: src/preferences.rs:428 msgid "Custom" msgstr "Personalizada" -#: src/preferences.rs:414 +#: src/preferences.rs:429 msgid "Article" msgstr "Artículo" -#: src/preferences.rs:415 +#: src/preferences.rs:430 msgid "Manuscript" msgstr "Manuscrito" -#: src/preferences.rs:416 +#: src/preferences.rs:431 msgid "Letter" msgstr "Carta" -#: src/preferences.rs:417 +#: src/preferences.rs:432 msgid "Compact" msgstr "Compacta" -#: src/preferences.rs:423 +#: src/preferences.rs:438 msgid "Monochrome prints everything in black on white" msgstr "Monocromo imprime todo en negro sobre blanco" -#: src/preferences.rs:428 +#: src/preferences.rs:443 msgid "Monochrome" msgstr "Monocromo" -#: src/preferences.rs:443 +#: src/preferences.rs:458 msgid "Font size" msgstr "Tamaño de fuente" -#: src/preferences.rs:444 +#: src/preferences.rs:459 msgid "Points" msgstr "Puntos" -#: src/preferences.rs:449 +#: src/preferences.rs:464 msgid "Monospace font" msgstr "Fuente monoespaciada" -#: src/preferences.rs:450 +#: src/preferences.rs:465 msgid "Used for code blocks and tables" msgstr "Se usa en bloques de código y tablas" -#: src/preferences.rs:459 +#: src/preferences.rs:474 msgid "Page size" msgstr "Tamaño de página" -#: src/preferences.rs:460 +#: src/preferences.rs:475 msgid "Paper" msgstr "Papel" -#: src/preferences.rs:465 +#: src/preferences.rs:480 msgid "Orientation" msgstr "Orientación" -#: src/preferences.rs:466 +#: src/preferences.rs:481 msgid "Paper orientation" msgstr "Orientación del papel" -#: src/preferences.rs:467 +#: src/preferences.rs:482 msgid "Portrait" msgstr "Vertical" -#: src/preferences.rs:467 +#: 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" @@ -761,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 5450e75..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-27 05:50+0100\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,19 +17,19 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" -#: src/main.rs:276 +#: src/main.rs:277 msgid "Recover unsaved documents?" msgstr "" -#: src/main.rs:279 +#: src/main.rs:280 msgid "_Recover All" msgstr "" -#: src/main.rs:280 +#: src/main.rs:281 msgid "_Discard All" msgstr "" -#: src/main.rs:281 +#: src/main.rs:282 msgid "Decide Later" msgstr "" @@ -278,310 +278,334 @@ msgstr "" 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:422 +#: 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:425 +#: src/preferences.rs:61 src/preferences.rs:440 msgid "System" msgstr "" -#: src/preferences.rs:60 src/preferences.rs:426 +#: src/preferences.rs:61 src/preferences.rs:441 msgid "Light" msgstr "" -#: src/preferences.rs:60 src/preferences.rs:427 +#: src/preferences.rs:61 src/preferences.rs:442 msgid "Dark" msgstr "" -#: src/preferences.rs:68 +#: src/preferences.rs:69 msgid "Automatic (follows the color scheme)" msgstr "" -#: src/preferences.rs:87 +#: src/preferences.rs:88 msgid "Text theme" msgstr "" -#: src/preferences.rs:88 +#: src/preferences.rs:89 msgid "Semantic colors of the document, applied live" msgstr "" -#: src/preferences.rs:108 +#: src/preferences.rs:109 msgid "Text" msgstr "" -#: src/preferences.rs:109 +#: src/preferences.rs:110 msgid "Code and tables always use a monospaced font" msgstr "" -#: src/preferences.rs:113 src/preferences.rs:433 +#: src/preferences.rs:114 src/preferences.rs:448 msgid "Font family" msgstr "" -#: src/preferences.rs:114 src/preferences.rs:434 +#: src/preferences.rs:115 src/preferences.rs:449 msgid "Document body" msgstr "" -#: src/preferences.rs:116 src/preferences.rs:436 +#: src/preferences.rs:117 src/preferences.rs:451 msgid "Sans (Cantarell)" msgstr "" -#: src/preferences.rs:117 src/preferences.rs:437 +#: src/preferences.rs:118 src/preferences.rs:452 msgid "Serif" msgstr "" -#: src/preferences.rs:118 src/preferences.rs:438 +#: src/preferences.rs:119 src/preferences.rs:453 msgid "Monospace" msgstr "" -#: src/preferences.rs:123 +#: src/preferences.rs:124 msgid "Size" msgstr "" -#: src/preferences.rs:124 +#: src/preferences.rs:125 msgid "Pixels" msgstr "" -#: src/preferences.rs:129 +#: src/preferences.rs:130 msgid "Line spacing" msgstr "" -#: src/preferences.rs:130 +#: src/preferences.rs:131 msgid "Multiplier on the font size" msgstr "" -#: src/preferences.rs:135 +#: src/preferences.rs:136 msgid "Column width" msgstr "" -#: src/preferences.rs:136 +#: src/preferences.rs:137 msgid "Maximum text width, in pixels" msgstr "" -#: src/preferences.rs:199 +#: src/preferences.rs:200 msgid "Editor" msgstr "" -#: src/preferences.rs:204 +#: src/preferences.rs:205 msgid "Markup" msgstr "" -#: src/preferences.rs:206 +#: src/preferences.rs:207 msgid "What to do with asterisks, hash marks and URLs while you write" msgstr "" -#: src/preferences.rs:216 src/preferences.rs:227 +#: src/preferences.rs:217 src/preferences.rs:228 msgid "Markdown marks" msgstr "" -#: src/preferences.rs:217 +#: src/preferences.rs:218 msgid "“On focus” reveals them only on the cursor line" msgstr "" -#: src/preferences.rs:219 +#: src/preferences.rs:220 msgid "Always hide" msgstr "" -#: src/preferences.rs:220 +#: src/preferences.rs:221 msgid "Show on focus" msgstr "" -#: src/preferences.rs:221 src/preferences.rs:232 +#: src/preferences.rs:222 src/preferences.rs:233 msgid "Always dim" msgstr "" -#: src/preferences.rs:228 +#: src/preferences.rs:229 msgid "Hiding text still crashes this GTK: the marks shrink instead" msgstr "" -#: src/preferences.rs:230 +#: src/preferences.rs:231 msgid "Always hide (shrinks the marks; GTK cannot hide them yet)" msgstr "" -#: src/preferences.rs:231 +#: src/preferences.rs:232 msgid "Show on focus (shrinks the marks; GTK cannot hide them yet)" msgstr "" -#: src/preferences.rs:241 +#: src/preferences.rs:242 msgid "Writing" msgstr "" -#: src/preferences.rs:244 +#: src/preferences.rs:245 msgid "Continue lists" msgstr "" -#: src/preferences.rs:246 +#: src/preferences.rs:247 msgid "Enter repeats the dash or number; on an empty item, it closes the list" msgstr "" -#: src/preferences.rs:251 +#: src/preferences.rs:252 msgid "Focus mode" msgstr "" -#: src/preferences.rs:252 +#: src/preferences.rs:253 msgid "Dims everything except the current paragraph" msgstr "" -#: src/preferences.rs:256 +#: src/preferences.rs:257 msgid "Typewriter mode" msgstr "" -#: src/preferences.rs:257 +#: src/preferences.rs:258 msgid "Keeps the cursor line vertically centered" msgstr "" -#: src/preferences.rs:261 +#: src/preferences.rs:262 msgid "Spell checking" msgstr "" -#: src/preferences.rs:263 +#: src/preferences.rs:264 msgid "Underlines misspelt words; right-click offers fixes" msgstr "" -#: src/preferences.rs:268 +#: src/preferences.rs:269 msgid "Tab width" msgstr "" -#: src/preferences.rs:269 +#: src/preferences.rs:270 msgid "Spaces per indent level" msgstr "" -#: src/preferences.rs:280 +#: src/preferences.rs:281 msgid "Saving" msgstr "" -#: src/preferences.rs:283 +#: src/preferences.rs:284 msgid "Autosave" msgstr "" -#: src/preferences.rs:284 +#: src/preferences.rs:285 msgid "Only when the document already has a file" msgstr "" -#: src/preferences.rs:288 +#: src/preferences.rs:289 msgid "Interval" msgstr "" -#: src/preferences.rs:289 +#: src/preferences.rs:290 msgid "Seconds between saves" msgstr "" -#: src/preferences.rs:306 +#: src/preferences.rs:307 msgid "History" msgstr "" -#: src/preferences.rs:309 +#: src/preferences.rs:310 msgid "Clear" msgstr "" -#: src/preferences.rs:314 +#: src/preferences.rs:315 msgid "Recent documents" msgstr "" -#: src/preferences.rs:315 +#: src/preferences.rs:316 msgid "Clears the list shown in the sidebar" msgstr "" -#: src/preferences.rs:398 +#: src/preferences.rs:401 +msgid "None" +msgstr "" + +#: src/preferences.rs:413 msgid "Export" msgstr "" -#: src/preferences.rs:403 +#: src/preferences.rs:418 msgid "PDF" msgstr "" -#: src/preferences.rs:405 +#: src/preferences.rs:420 msgid "These settings apply to PDF export only, not to the editor view" msgstr "" -#: src/preferences.rs:410 +#: src/preferences.rs:425 msgid "Template" msgstr "" -#: src/preferences.rs:411 +#: src/preferences.rs:426 msgid "Page layout preset; Custom uses the settings below" msgstr "" -#: src/preferences.rs:413 +#: src/preferences.rs:428 msgid "Custom" msgstr "" -#: src/preferences.rs:414 +#: src/preferences.rs:429 msgid "Article" msgstr "" -#: src/preferences.rs:415 +#: src/preferences.rs:430 msgid "Manuscript" msgstr "" -#: src/preferences.rs:416 +#: src/preferences.rs:431 msgid "Letter" msgstr "" -#: src/preferences.rs:417 +#: src/preferences.rs:432 msgid "Compact" msgstr "" -#: src/preferences.rs:423 +#: src/preferences.rs:438 msgid "Monochrome prints everything in black on white" msgstr "" -#: src/preferences.rs:428 +#: src/preferences.rs:443 msgid "Monochrome" msgstr "" -#: src/preferences.rs:443 +#: src/preferences.rs:458 msgid "Font size" msgstr "" -#: src/preferences.rs:444 +#: src/preferences.rs:459 msgid "Points" msgstr "" -#: src/preferences.rs:449 +#: src/preferences.rs:464 msgid "Monospace font" msgstr "" -#: src/preferences.rs:450 +#: src/preferences.rs:465 msgid "Used for code blocks and tables" msgstr "" -#: src/preferences.rs:459 +#: src/preferences.rs:474 msgid "Page size" msgstr "" -#: src/preferences.rs:460 +#: src/preferences.rs:475 msgid "Paper" msgstr "" -#: src/preferences.rs:465 +#: src/preferences.rs:480 msgid "Orientation" msgstr "" -#: src/preferences.rs:466 +#: src/preferences.rs:481 msgid "Paper orientation" msgstr "" -#: src/preferences.rs:467 +#: src/preferences.rs:482 msgid "Portrait" msgstr "" -#: src/preferences.rs:467 +#: 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 "" 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::>>()?; + let (r, g, b) = match digitos.len() { + 3 | 4 => (digitos[0] * 17, digitos[1] * 17, digitos[2] * 17), + 6 | 8 => ( + digitos[0] * 16 + digitos[1], + digitos[2] * 16 + digitos[3], + digitos[4] * 16 + digitos[5], + ), + _ => return None, + }; + Some((r as f64 / 255.0, g as f64 / 255.0, b as f64 / 255.0)) +} + +/// Nombres de color admitidos (RGB de 8 bits). +fn nombre_color(nombre: &str) -> Option<(u32, u32, u32)> { + let rgb = match nombre { + "black" => (0, 0, 0), + "white" => (255, 255, 255), + "gray" | "grey" => (128, 128, 128), + "silver" => (192, 192, 192), + "maroon" => (128, 0, 0), + "red" => (255, 0, 0), + "orange" => (255, 165, 0), + "green" => (0, 128, 0), + "teal" => (0, 128, 128), + "navy" => (0, 0, 128), + "blue" => (0, 0, 255), + "purple" => (128, 0, 128), + _ => return None, + }; + Some(rgb) +} + +/// Familia tipográfica del cuerpo a partir de un `font-family`. El export solo +/// maneja tres familias, así que se busca la primera del listado que encaje. +fn familia(valor: &str) -> Option { + for candidata in valor.split(',') { + let nombre = candidata + .trim() + .trim_matches(|c| c == '"' || c == '\'') + .to_ascii_lowercase(); + let familia = match nombre.as_str() { + "serif" | "source serif 4" | "georgia" | "times" | "times new roman" | "garamond" + | "libertinus serif" => FontFamily::Serif, + "sans-serif" | "sans" | "system-ui" | "cantarell" | "helvetica" | "arial" + | "adwaita sans" | "inter" => FontFamily::Sans, + "monospace" | "mono" | "adwaita mono" | "source code pro" | "fira mono" + | "fira code" | "jetbrains mono" | "iosevka" | "courier" => FontFamily::Mono, + _ => continue, + }; + return Some(familia); + } + None +} + +/// Fuente monoespaciada del código a partir de un `font-family`. +fn fuente_mono(valor: &str) -> Option { + for candidata in valor.split(',') { + let nombre = candidata + .trim() + .trim_matches(|c| c == '"' || c == '\'') + .to_ascii_lowercase(); + match nombre.as_str() { + "adwaita mono" | "monospace" | "mono" => return Some(ExportMonoFont::AdwaitaMono), + "source code pro" => return Some(ExportMonoFont::SourceCodePro), + "fira mono" | "fira code" => return Some(ExportMonoFont::FiraMono), + _ => {} + } + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn css_las_longitudes_se_convierten_a_puntos() { + assert_eq!(longitud("72pt"), Some(72.0)); + assert_eq!(longitud("1in"), Some(72.0)); + assert_eq!(longitud("25.4mm"), Some(72.0)); + assert_eq!(longitud("2.54cm"), Some(72.0)); + assert_eq!(longitud("16px"), Some(12.0)); + assert_eq!(longitud("0"), Some(0.0)); + assert_eq!(longitud(" 12 PT "), Some(12.0)); + } + + #[test] + fn css_las_longitudes_invalidas_se_descartan() { + assert_eq!(longitud(""), None); + assert_eq!(longitud("auto"), None); + assert_eq!(longitud("1em"), None); + assert_eq!(longitud("-4pt"), None); + assert_eq!(longitud("inf"), None); + assert_eq!(longitud("nan"), None); + } + + #[test] + fn css_el_margen_de_pagina_acepta_atajos_de_uno_a_cuatro_valores() { + let uno = analizar("@page { margin: 1in }"); + assert_eq!(uno.margenes, MargenesCss::uniforme(72.0)); + + let dos = analizar("@page { margin: 10mm 20mm }"); + assert_eq!( + dos.margenes, + MargenesCss { + superior: Some(10.0 * PT_POR_MM), + inferior: Some(10.0 * PT_POR_MM), + izquierda: Some(20.0 * PT_POR_MM), + derecha: Some(20.0 * PT_POR_MM), + } + ); + + let tres = analizar("@page { margin: 10pt 20pt 30pt }"); + assert_eq!(tres.margenes.superior, Some(10.0)); + assert_eq!(tres.margenes.inferior, Some(30.0)); + assert_eq!(tres.margenes.izquierda, Some(20.0)); + assert_eq!(tres.margenes.derecha, Some(20.0)); + + let cuatro = analizar("@page { margin: 10pt 20pt 30pt 40pt }"); + assert_eq!(cuatro.margenes.superior, Some(10.0)); + assert_eq!(cuatro.margenes.derecha, Some(20.0)); + assert_eq!(cuatro.margenes.inferior, Some(30.0)); + assert_eq!(cuatro.margenes.izquierda, Some(40.0)); + } + + #[test] + fn css_los_margenes_por_lado_pisan_al_atajo() { + let a = analizar("@page { margin: 1in; margin-left: 2in }"); + assert_eq!(a.margenes.izquierda, Some(144.0)); + assert_eq!(a.margenes.superior, Some(72.0)); + } + + #[test] + fn css_la_tipografia_sale_del_cuerpo_y_de_las_cabeceras() { + let a = analizar( + "body { font-family: \"Source Serif 4\", serif; font-size: 12pt; line-height: 1.8 } + h1 { font-size: 24pt; color: #123 } + h3 { font-size: 14pt }", + ); + assert_eq!(a.familia, Some(FontFamily::Serif)); + assert_eq!(a.tamano_cuerpo, Some(12.0)); + assert_eq!(a.interlinea, Some(1.8)); + assert_eq!(a.tamanos_cabecera[1], Some(24.0)); + assert_eq!(a.tamanos_cabecera[3], Some(14.0)); + assert_eq!(a.tamanos_cabecera[2], None); + assert_eq!( + a.colores.cabecera, + Some((17.0 / 255.0, 34.0 / 255.0, 51.0 / 255.0)) + ); + } + + #[test] + fn css_el_interlineado_admite_porcentaje_y_descarta_absurdos() { + assert_eq!(analizar("p { line-height: 150% }").interlinea, Some(1.5)); + assert_eq!(analizar("p { line-height: 0 }").interlinea, None); + assert_eq!(analizar("p { line-height: 12 }").interlinea, None); + assert_eq!(analizar("p { line-height: auto }").interlinea, None); + } + + #[test] + fn css_el_espaciado_entre_bloques_sale_de_margin_bottom_o_de_la_variable() { + assert_eq!( + analizar("p { margin-bottom: 6pt }").espacio_bloque, + Some(6.0) + ); + assert_eq!( + analizar("body { --block-spacing: 4mm }").espacio_bloque, + Some(4.0 * PT_POR_MM) + ); + } + + #[test] + fn css_los_colores_por_elemento_van_a_su_campo() { + let a = analizar( + "body { color: #222; background-color: white } + p { color: rgb(10, 20, 30) } + pre { color: #333; background-color: #f5f5f5 } + blockquote { color: #444 } + a { color: blue } + table { background-color: #eee }", + ); + assert_eq!(a.colores.fondo, Some((1.0, 1.0, 1.0))); + assert_eq!( + a.colores.texto, + Some((10.0 / 255.0, 20.0 / 255.0, 30.0 / 255.0)) + ); + assert_eq!(a.colores.codigo, Some((0.2, 0.2, 0.2))); + assert_eq!( + a.colores.codigo_fondo, + Some((245.0 / 255.0, 245.0 / 255.0, 245.0 / 255.0)) + ); + assert_eq!( + a.colores.cita, + Some((68.0 / 255.0, 68.0 / 255.0, 68.0 / 255.0)) + ); + assert_eq!(a.colores.enlace, Some((0.0, 0.0, 1.0))); + assert_eq!( + a.colores.tabla_fondo, + Some((238.0 / 255.0, 238.0 / 255.0, 238.0 / 255.0)) + ); + } + + #[test] + fn css_los_colores_invalidos_se_descartan() { + assert_eq!(color("#zzz"), None); + assert_eq!(color("#12345"), None); + assert_eq!(color("rgb(1, 2)"), None); + assert_eq!(color("chartreuse"), None); + assert_eq!(color(""), None); + assert_eq!(color("#fff"), Some((1.0, 1.0, 1.0))); + assert_eq!(color("rgba(255, 0, 0, 0.5)"), Some((1.0, 0.0, 0.0))); + } + + #[test] + fn css_la_familia_monoespaciada_elige_la_fuente_del_codigo() { + assert_eq!( + analizar("code { font-family: \"Source Code Pro\", monospace }").fuente_mono, + Some(ExportMonoFont::SourceCodePro) + ); + assert_eq!( + analizar("pre { font-family: Fira Code }").fuente_mono, + Some(ExportMonoFont::FiraMono) + ); + assert_eq!(analizar("pre { font-family: Papyrus }").fuente_mono, None); + } + + #[test] + fn css_los_selectores_con_clase_o_pseudoclase_cuentan_por_su_elemento() { + let a = analizar("p.nota:hover { color: black } h2:first-child { font-size: 18pt }"); + assert_eq!(a.colores.texto, Some((0.0, 0.0, 0.0))); + assert_eq!(a.tamanos_cabecera[2], Some(18.0)); + } + + #[test] + fn css_los_comentarios_y_las_arrobas_se_ignoran() { + let a = analizar( + "/* body { color: red } */ + @charset \"utf-8\"; + @media print { body { color: red } } + body { color: black }", + ); + assert_eq!(a.colores.texto, Some((0.0, 0.0, 0.0))); + assert_eq!(a.colores.fondo, None); + } + + #[test] + fn css_las_propiedades_desconocidas_se_apuntan_una_sola_vez() { + let a = analizar( + "p { padding: 4pt; padding: 8pt; font-variant: small-caps } + body { color: black }", + ); + assert_eq!( + a.ignoradas, + vec!["padding".to_string(), "font-variant".to_string()] + ); + } + + #[test] + fn css_un_fichero_vacio_no_impone_nada() { + assert!(analizar("").es_vacio()); + assert!(analizar(" /* nada */ ").es_vacio()); + assert!(analizar("section { color: red }").es_vacio()); + assert!(!analizar("body { color: black }").es_vacio()); + } + + #[test] + fn css_los_selectores_compuestos_usan_el_ultimo_elemento() { + assert_eq!(elemento("body > p"), Some(Elemento::Parrafo)); + assert_eq!(elemento("html body"), Some(Elemento::Raiz)); + assert_eq!(elemento("*"), Some(Elemento::Raiz)); + assert_eq!(elemento("table td"), None); + assert_eq!(elemento(""), None); + } +} diff --git a/src/export_pdf.rs b/src/export_pdf.rs index 5f4e0d7..b746a73 100644 --- a/src/export_pdf.rs +++ b/src/export_pdf.rs @@ -48,6 +48,7 @@ use std::rc::Rc; use gtk4::cairo; use gtk4::prelude::*; +use crate::export_css::Ajustes; use crate::markdown_render::{analyze, Analysis, Ornament, SpanKind}; use crate::settings::{ AppSettings, ExportColorScheme, ExportMonoFont, ExportOrientation, ExportPageSize, @@ -102,6 +103,8 @@ pub struct ExportConfig { pub papel: ExportPageSize, pub orientacion: ExportOrientation, pub template: ExportTemplate, + /// Hoja de estilo del usuario, aplicada encima de la plantilla. + pub css_path: Option, } impl ExportConfig { @@ -116,6 +119,7 @@ impl ExportConfig { papel: s.export_page_size(), orientacion: s.export_orientation(), template: s.export_template(), + css_path: s.export_css_path(), } } @@ -301,6 +305,95 @@ impl Plantilla { } } +// --------------------------------------------------------------------------- +// Hoja de estilo del usuario +// --------------------------------------------------------------------------- + +/// Carga la hoja de estilo elegida. Un fichero ilegible no aborta la +/// exportación: se avisa por stderr y se sigue con la plantilla. +fn cargar_hoja(config: &ExportConfig) -> Option { + let ruta = config.css_path.as_ref()?; + match crate::export_css::cargar(ruta) { + Ok(ajustes) => { + if !ajustes.ignoradas.is_empty() { + eprintln!( + "hoja de estilo {}: propiedades sin efecto en el export: {}", + ruta.display(), + ajustes.ignoradas.join(", ") + ); + } + if ajustes.es_vacio() { + None + } else { + Some(ajustes) + } + } + Err(e) => { + eprintln!("no se pudo leer la hoja de estilo {}: {e}", ruta.display()); + None + } + } +} + +/// Aplica encima de la plantilla lo que impone la hoja de estilo. Lo que la +/// hoja no menciona se queda como estaba. +fn aplicar_hoja( + ajustes: &Ajustes, + plantilla: &mut Plantilla, + config: &mut ExportConfig, + paleta: &mut Paleta, +) { + if let Some(interlinea) = ajustes.interlinea { + plantilla.interlinea = interlinea; + } + if let Some(espacio) = ajustes.espacio_bloque { + plantilla.espacio_bloque = espacio; + } + if let Some(familia) = ajustes.familia { + config.familia_cuerpo = familia; + } + if let Some(fuente) = ajustes.fuente_mono { + config.fuente_mono = fuente; + } + if let Some(tamano) = ajustes.tamano_cuerpo { + config.tamano_cuerpo = tamano; + } + // El tamaño absoluto de cada cabecera se convierte en escala respecto al + // cuerpo ya resuelto. + for nivel in 1..=4usize { + if let Some(tamano) = ajustes.tamanos_cabecera[nivel] { + if config.tamano_cuerpo > 0.0 { + plantilla.escala[nivel] = tamano / config.tamano_cuerpo; + } + } + } + let c = ajustes.colores; + if let Some(v) = c.fondo { + paleta.fondo = v; + } + if let Some(v) = c.texto { + paleta.texto = v; + } + if let Some(v) = c.cabecera { + paleta.cabecera = v; + } + if let Some(v) = c.codigo { + paleta.codigo = v; + } + if let Some(v) = c.codigo_fondo { + paleta.codigo_fondo = v; + } + if let Some(v) = c.cita { + paleta.cita = v; + } + if let Some(v) = c.enlace { + paleta.enlace = v; + } + if let Some(v) = c.tabla_fondo { + paleta.tabla_fondo = v; + } +} + // --------------------------------------------------------------------------- // Paleta de colores // --------------------------------------------------------------------------- @@ -1800,8 +1893,13 @@ pub fn crear_operacion( titulo: &str, sistema_oscuro: bool, ) -> gtk4::PrintOperation { - let plantilla = Plantilla::desde(config); - let config = config.con_plantilla(&plantilla); + let mut plantilla = Plantilla::desde(config); + let mut config = config.con_plantilla(&plantilla); + let mut paleta = resolver_paleta(config.esquema, sistema_oscuro); + let hoja = cargar_hoja(&config); + if let Some(ajustes) = &hoja { + aplicar_hoja(ajustes, &mut plantilla, &mut config, &mut paleta); + } let op = gtk4::PrintOperation::new(); op.set_job_name(titulo); @@ -1815,6 +1913,21 @@ pub fn crear_operacion( setup.set_left_margin(m.izquierda, gtk4::Unit::Points); setup.set_right_margin(m.derecha, gtk4::Unit::Points); } + if let Some(ajustes) = &hoja { + let m = ajustes.margenes; + if let Some(v) = m.superior { + setup.set_top_margin(v, gtk4::Unit::Points); + } + if let Some(v) = m.inferior { + setup.set_bottom_margin(v, gtk4::Unit::Points); + } + if let Some(v) = m.izquierda { + setup.set_left_margin(v, gtk4::Unit::Points); + } + if let Some(v) = m.derecha { + setup.set_right_margin(v, gtk4::Unit::Points); + } + } setup.set_orientation(match config.orientacion { ExportOrientation::Portrait => gtk4::PageOrientation::Portrait, ExportOrientation::Landscape => gtk4::PageOrientation::Landscape, @@ -1826,9 +1939,15 @@ pub fn crear_operacion( if plantilla.bloque_titulo && !titulo.trim().is_empty() { iniciales.insert(0, bloque_titulo(titulo)); } + // Márgenes efectivos: el contexto de `draw_page` empieza en el área + // imprimible, así que hacen falta para pintar el fondo de la hoja entera. + let margen_sup = setup.top_margin(gtk4::Unit::Points); + let margen_inf = setup.bottom_margin(gtk4::Unit::Points); + let margen_izq = setup.left_margin(gtk4::Unit::Points); + let margen_der = setup.right_margin(gtk4::Unit::Points); + let bloques: Rc>> = Rc::new(RefCell::new(iniciales)); let paginas: Rc>> = Rc::new(RefCell::new(Vec::new())); - let paleta = resolver_paleta(config.esquema, sistema_oscuro); let banda_sup = plantilla.banda_superior(); let banda_inf = plantilla.banda_inferior(); let titulo_adorno = titulo.to_string(); @@ -1861,10 +1980,19 @@ pub fn crear_operacion( let ancho = ctx.width(); let alto_pagina = ctx.height(); - // Fondo de la página. + // Fondo de la hoja completa, márgenes incluidos: el contexto de + // `draw_page` solo cubre el área imprimible. + let _ = cr.save(); + cr.translate(-margen_izq, -margen_sup); cr.set_source_rgb(paleta.fondo.0, paleta.fondo.1, paleta.fondo.2); - cr.rectangle(0.0, 0.0, ancho, alto_pagina); + cr.rectangle( + 0.0, + 0.0, + ancho + margen_izq + margen_der, + alto_pagina + margen_sup + margen_inf, + ); let _ = cr.fill(); + let _ = cr.restore(); let paginas = paginas.borrow(); let bloques = bloques.borrow(); @@ -2212,6 +2340,7 @@ mod tests { papel: ExportPageSize::A4, orientacion: ExportOrientation::Portrait, template, + css_path: None, } } @@ -2309,4 +2438,52 @@ mod tests { assert_eq!(plantilla.escala_cabecera(0), 1.0); assert_eq!(plantilla.escala_cabecera(9), 1.0); } + + #[test] + fn pdf_la_hoja_de_estilo_pisa_a_la_plantilla() { + let config = config_de(ExportTemplate::Manuscript); + let mut plantilla = Plantilla::desde(&config); + let mut config = config.con_plantilla(&plantilla); + let mut paleta = resolver_paleta(config.esquema, false); + let ajustes = crate::export_css::analizar( + "body { font-family: sans-serif; font-size: 10pt; line-height: 1.2; color: #111 } + h1 { font-size: 20pt } + p { margin-bottom: 6pt } + @page { margin: 30pt }", + ); + aplicar_hoja(&ajustes, &mut plantilla, &mut config, &mut paleta); + assert_eq!(config.familia_cuerpo, FontFamily::Sans); + assert_eq!(config.tamano_cuerpo, 10.0); + assert_eq!(plantilla.interlinea, 1.2); + assert_eq!(plantilla.espacio_bloque, 6.0); + assert_eq!(plantilla.escala_cabecera(1), 2.0); + assert_eq!(plantilla.escala_cabecera(2), 1.3); + assert_eq!(paleta.texto, (17.0 / 255.0, 17.0 / 255.0, 17.0 / 255.0)); + assert_eq!(ajustes.margenes.superior, Some(30.0)); + assert_eq!(ajustes.margenes.izquierda, Some(30.0)); + } + + #[test] + fn pdf_una_hoja_que_no_se_puede_leer_no_cambia_el_export() { + let mut config = config_de(ExportTemplate::Article); + config.css_path = Some(std::path::PathBuf::from("/no/existe/estilo.css")); + assert!(cargar_hoja(&config).is_none()); + config.css_path = None; + assert!(cargar_hoja(&config).is_none()); + } + + #[test] + fn pdf_una_hoja_vacia_equivale_a_no_tenerla() { + let dir = std::env::temp_dir().join("scribe-css-vacia"); + let _ = std::fs::create_dir_all(&dir); + let ruta = dir.join("vacia.css"); + std::fs::write(&ruta, "/* nada que aplicar */\nsection { padding: 2pt }\n") + .expect("escribir css"); + let mut config = config_de(ExportTemplate::Article); + config.css_path = Some(ruta.clone()); + assert!(cargar_hoja(&config).is_none()); + std::fs::write(&ruta, "body { color: black }").expect("escribir css"); + assert!(cargar_hoja(&config).is_some()); + let _ = std::fs::remove_file(&ruta); + } } diff --git a/src/main.rs b/src/main.rs index 3afdaee..0fa23d6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -11,6 +11,7 @@ use std::sync::atomic::{AtomicBool, Ordering}; mod arranque; mod editor; +mod export_css; mod export_pdf; mod file_manager; mod guardado; diff --git a/src/preferences.rs b/src/preferences.rs index 6284131..afe3b01 100644 --- a/src/preferences.rs +++ b/src/preferences.rs @@ -2,6 +2,7 @@ //! según las HIG de GNOME: apariencia, editor y exportación. use gettextrs::gettext; +use gio::prelude::*; use gtk4::prelude::*; use libadwaita as adw; use libadwaita::prelude::*; @@ -393,6 +394,20 @@ fn editor_page(settings: &Rc, apply: &Rc) -> adw::Prefere page } +/// Descripción de la hoja de estilo elegida para la fila de preferencias. +fn describir_hoja(settings: &AppSettings) -> String { + match settings.export_css_path() { + Some(ruta) => ruta.display().to_string(), + None => gettext("None"), + } +} + +/// Refresca la fila de la hoja de estilo tras elegirla o quitarla. +fn refrescar_hoja(fila: &adw::ActionRow, quitar: >k4::Button, settings: &AppSettings) { + fila.set_subtitle(&describir_hoja(settings)); + quitar.set_sensitive(settings.export_css_path().is_some()); +} + fn export_page(settings: &Rc, apply: &Rc) -> adw::PreferencesPage { let page = adw::PreferencesPage::builder() .title(gettext("Export")) @@ -468,6 +483,23 @@ fn export_page(settings: &Rc, apply: &Rc) -> adw::Prefere settings.export_orientation().index(), ); + let estilos = adw::ActionRow::builder() + .title(gettext("Stylesheet")) + .subtitle(describir_hoja(settings)) + .build(); + let cargar_estilos = gtk4::Button::builder() + .label(gettext("Load…")) + .valign(gtk4::Align::Center) + .build(); + let quitar_estilos = gtk4::Button::builder() + .icon_name("edit-clear-symbolic") + .tooltip_text(gettext("Use the template as is")) + .valign(gtk4::Align::Center) + .build(); + quitar_estilos.set_sensitive(settings.export_css_path().is_some()); + estilos.add_suffix(&cargar_estilos); + estilos.add_suffix(&quitar_estilos); + // Los ajustes granulares solo mandan con la plantilla Custom. let granulares: Vec = vec![ scheme.clone().upcast(), @@ -484,6 +516,7 @@ fn export_page(settings: &Rc, apply: &Rc) -> adw::Prefere group.add(row); } group.add(&size); + group.add(&estilos); page.add(&group); { @@ -547,6 +580,50 @@ fn export_page(settings: &Rc, apply: &Rc) -> adw::Prefere apply(); }); } + { + let settings = settings.clone(); + let apply = apply.clone(); + let estilos = estilos.clone(); + let quitar = quitar_estilos.clone(); + cargar_estilos.connect_clicked(move |boton| { + let ventana = boton.root().and_then(|r| r.downcast::().ok()); + let filtro = gtk4::FileFilter::new(); + filtro.set_name(Some(&gettext("CSS stylesheets"))); + filtro.add_suffix("css"); + let filtros = gio::ListStore::new::(); + filtros.append(&filtro); + let dialog = gtk4::FileDialog::builder() + .title(gettext("Choose a stylesheet")) + .filters(&filtros) + .build(); + let settings = settings.clone(); + let apply = apply.clone(); + let estilos = estilos.clone(); + let quitar = quitar.clone(); + dialog.open(ventana.as_ref(), gio::Cancellable::NONE, move |resultado| { + let Ok(fichero) = resultado else { + return; + }; + let Some(ruta) = fichero.path() else { + return; + }; + settings.set_export_css_path(Some(&ruta)); + refrescar_hoja(&estilos, &quitar, &settings); + apply(); + }); + }); + } + { + let settings = settings.clone(); + let apply = apply.clone(); + let estilos = estilos.clone(); + let fila = quitar_estilos.clone(); + quitar_estilos.connect_clicked(move |_| { + settings.set_export_css_path(None); + refrescar_hoja(&estilos, &fila, &settings); + apply(); + }); + } page } diff --git a/src/settings.rs b/src/settings.rs index 3b3f6c4..e04aef4 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -664,4 +664,24 @@ impl AppSettings { } }); } + + /// Ruta de la hoja de estilo del usuario. Vacía significa "ninguna". + pub fn export_css_path(&self) -> Option { + let ruta = self.get(String::new(), |s| s.string("export-css-path").to_string()); + if ruta.trim().is_empty() { + None + } else { + Some(std::path::PathBuf::from(ruta)) + } + } + pub fn set_export_css_path(&self, ruta: Option<&std::path::Path>) { + let valor = ruta + .map(|r| r.to_string_lossy().into_owned()) + .unwrap_or_default(); + self.set(|s| { + if let Err(e) = s.set_string("export-css-path", &valor) { + log_set_error("export-css-path", e); + } + }); + } } diff --git a/tests/export_pdf.rs b/tests/export_pdf.rs index d8b30ef..fce6488 100644 --- a/tests/export_pdf.rs +++ b/tests/export_pdf.rs @@ -8,6 +8,8 @@ // use (mismo criterio que tests/render_shot.rs). #![allow(dead_code)] +#[path = "../src/export_css.rs"] +mod export_css; #[path = "../src/export_pdf.rs"] mod export_pdf; #[path = "../src/markdown_render.rs"] @@ -61,6 +63,75 @@ fn main() { This text lands on a new page because of the explicit `\\newpage` above. "; +/// Vuelca a PNG la primera página del PDF. `None` si no hay poppler. +fn raster(pdf: &std::path::Path, prefijo: &std::path::Path) -> Option> { + let salida = std::process::Command::new("pdftoppm") + .arg("-png") + .arg("-r") + .arg("60") + .arg("-f") + .arg("1") + .arg("-l") + .arg("1") + .arg(pdf) + .arg(prefijo) + .output() + .ok()?; + if !salida.status.success() { + return None; + } + std::fs::read(format!("{}-1.png", prefijo.display())).ok() +} + +#[test] +#[ignore = "requiere display: xvfb-run -a cargo test --test export_pdf -- --ignored --nocapture"] +fn la_hoja_de_estilo_cambia_el_pdf_y_quitarla_lo_deja_igual() { + gtk4::init().expect("GTK necesita un display; ejecuta bajo xvfb-run"); + libadwaita::init().expect("libadwaita init"); + + let dir = std::env::temp_dir().join("scribe-css-prueba"); + std::fs::create_dir_all(&dir).expect("crear directorio de prueba"); + let css = dir.join("estilo.css"); + std::fs::write( + &css, + "body { font-size: 14pt; color: #003366; background-color: #fffdf5 }\n@page { margin: 48pt }\n", + ) + .expect("escribir css"); + + let base = export_pdf::ExportConfig { + esquema: settings::ExportColorScheme::Light, + familia_cuerpo: settings::FontFamily::Sans, + tamano_cuerpo: 11.0, + fuente_mono: settings::ExportMonoFont::AdwaitaMono, + papel: settings::ExportPageSize::A4, + orientacion: settings::ExportOrientation::Portrait, + template: settings::ExportTemplate::Custom, + css_path: None, + }; + let con_hoja = export_pdf::ExportConfig { + css_path: Some(css.clone()), + ..base.clone() + }; + + let sin = dir.join("sin-hoja.pdf"); + let con = dir.join("con-hoja.pdf"); + let otra_vez = dir.join("otra-vez.pdf"); + export_pdf::exportar_a_fichero(&base, DOC, "Prueba", &sin, false).expect("exportar sin hoja"); + export_pdf::exportar_a_fichero(&con_hoja, DOC, "Prueba", &con, false) + .expect("exportar con hoja"); + export_pdf::exportar_a_fichero(&base, DOC, "Prueba", &otra_vez, false).expect("reexportar"); + + let Some(a) = raster(&sin, &dir.join("a")) else { + eprintln!("pdftoppm no disponible: se omite la comparación visual"); + return; + }; + let b = raster(&con, &dir.join("b")).expect("rasterizar el PDF con hoja"); + let c = raster(&otra_vez, &dir.join("c")).expect("rasterizar el PDF sin hoja"); + assert_eq!(a, c, "quitar la hoja debe dejar la página como estaba"); + assert_ne!(a, b, "la hoja de estilo debe cambiar la página"); + eprintln!("PDFs de prueba en {}", dir.display()); +} + #[test] #[ignore = "requiere display: xvfb-run -a cargo test --test export_pdf -- --ignored --nocapture"] fn exporta_un_pdf_real_de_prueba() { @@ -77,6 +148,7 @@ fn exporta_un_pdf_real_de_prueba() { papel: settings::ExportPageSize::A4, orientacion: settings::ExportOrientation::Portrait, template: settings::ExportTemplate::Custom, + css_path: None, }; export_pdf::exportar_a_fichero(&config, DOC, "Prueba", &ruta, false).expect("exportar a PDF"); diff --git a/tests/tabs_smoke.rs b/tests/tabs_smoke.rs index 06bc8e0..319d29d 100644 --- a/tests/tabs_smoke.rs +++ b/tests/tabs_smoke.rs @@ -16,6 +16,8 @@ mod tema; #[path = "../src/editor.rs"] mod editor; +#[path = "../src/export_css.rs"] +mod export_css; #[path = "../src/export_pdf.rs"] mod export_pdf; #[path = "../src/file_manager.rs"]