mirror of
https://github.com/storytold/deckcraft.git
synced 2026-11-07 10:55:52 +00:00
Merge pull request #1192 from MarekSpodBiedry/issue-1153
Use evaluated field values in the placed text so the PDF text layer shows the slide number (#1153)
This commit is contained in:
5 files changed
+110
-16
No files matched your search
@@ -307,7 +307,7 @@ pub fn master_text_pieces(pres: &Presentation, m: &deckcraft_model::Master, pape
|
||||
.body
|
||||
.paragraphs
|
||||
.get(run.para)
|
||||
.map(|p| p.runs.iter().map(|r| r.text.as_str()).collect::<String>())
|
||||
.map(|p| p.runs.iter().map(|r| r.content()).collect::<String>())
|
||||
.unwrap_or_default()
|
||||
.chars()
|
||||
.skip(run.chars.0)
|
||||
@@ -522,7 +522,8 @@ impl Exporter<'_> {
|
||||
let k = b.width() / sw;
|
||||
// Raster at the requested dpi relative to the box size on paper.
|
||||
let scale = raster_scale(b.width(), (sw, sh), self.opts.dpi);
|
||||
let ropts = RenderOpts { scale, threads: 4, ..Default::default() };
|
||||
// One clock reading for the raster and the text layer, so a date field cannot differ between them.
|
||||
let ropts = RenderOpts { scale, threads: 4, now: deckcraft_text::datetime::DateTime::now_local(), ..Default::default() };
|
||||
// Two rasters per slide: the backdrop (background, master and layout graphics) is the same
|
||||
// on every slide that shares its layout, so the writer stores it once, and the slide's own
|
||||
// shapes draw over it (#845).
|
||||
@@ -563,7 +564,7 @@ impl Exporter<'_> {
|
||||
}
|
||||
if self.opts.text_layer {
|
||||
let view = Affine::translate((b.x0, b.y0)) * Affine::scale(k);
|
||||
for pt in deckcraft_render::place_slide(pres, i).texts {
|
||||
for pt in deckcraft_render::place_slide_with(pres, i, &ropts).texts {
|
||||
self.text_layer(s, &pt, view, false)?;
|
||||
}
|
||||
let _ = sh;
|
||||
@@ -581,7 +582,7 @@ impl Exporter<'_> {
|
||||
.body
|
||||
.paragraphs
|
||||
.get(run.para)
|
||||
.map(|p| p.runs.iter().map(|r| r.text.as_str()).collect::<String>())
|
||||
.map(|p| p.runs.iter().map(|r| r.content()).collect::<String>())
|
||||
.unwrap_or_default()
|
||||
.chars()
|
||||
.skip(run.chars.0)
|
||||
|
||||
+56
-5
@@ -269,8 +269,6 @@ fn unembeddable_font_returns_error_instead_of_a_pdf_missing_text() {
|
||||
#[cfg_attr(not(target_arch = "wasm32"), test)]
|
||||
fn supported_unicode_is_shaped_and_recovered_from_the_page_text_layer() {
|
||||
use deckcraft_model::{Shape, ShapeId, Xfrm};
|
||||
use hayro_syntax::content::ops::TypedInstruction;
|
||||
use hayro_syntax::object::{Dict, Object, Stream};
|
||||
|
||||
let text = "Café Ελληνικά Привет";
|
||||
let face = FontDb::global().face("Ubuntu", "Regular");
|
||||
@@ -292,8 +290,17 @@ fn supported_unicode_is_shaped_and_recovered_from_the_page_text_layer() {
|
||||
assert!(runs.iter().all(|run| run.face.family == "Ubuntu" && !run.glyphs.is_empty() && run.glyphs.iter().all(|glyph| glyph.0 != 0)));
|
||||
|
||||
let bytes = export(&p, &opts(PageLayout::Slides)).unwrap();
|
||||
let pdf = hayro_syntax::Pdf::new(bytes.clone()).unwrap();
|
||||
let page = &pdf.pages()[0];
|
||||
assert_eq!(page_text(&bytes, 0), text);
|
||||
}
|
||||
|
||||
/// The text of page `index`, read the way a PDF reader does: the content stream's text-showing
|
||||
/// operators decoded through the page font's ToUnicode map.
|
||||
fn page_text(bytes: &[u8], index: usize) -> String {
|
||||
use hayro_syntax::content::ops::TypedInstruction;
|
||||
use hayro_syntax::object::{Dict, Object, Stream};
|
||||
|
||||
let pdf = hayro_syntax::Pdf::new(bytes.to_vec()).unwrap();
|
||||
let page = &pdf.pages()[index];
|
||||
let resources = page.raw().get::<Dict<'_>>(b"Resources").unwrap();
|
||||
let fonts = resources.get::<Dict<'_>>(b"Font").unwrap();
|
||||
assert_eq!(fonts.len(), 1);
|
||||
@@ -344,7 +351,51 @@ fn supported_unicode_is_shaped_and_recovered_from_the_page_text_layer() {
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
assert_eq!(extracted, text);
|
||||
extracted
|
||||
}
|
||||
|
||||
fn ubuntu_runs(runs: Vec<deckcraft_model::text::Run>) -> TextBody {
|
||||
let paragraph = deckcraft_model::text::Paragraph { runs, ..Default::default() };
|
||||
let mut body = TextBody { paragraphs: vec![paragraph], ..Default::default() };
|
||||
for run in &mut body.paragraphs[0].runs {
|
||||
run.props.font = Some("Ubuntu".into());
|
||||
run.props.size = Some(20.0);
|
||||
}
|
||||
body
|
||||
}
|
||||
|
||||
fn deck_with_body(n: usize, body: TextBody) -> Presentation {
|
||||
use deckcraft_model::{Shape, ShapeId, Xfrm};
|
||||
let mut p = deck(n);
|
||||
for slide in &mut p.slides {
|
||||
Arc::make_mut(slide).shapes =
|
||||
vec![Shape { id: ShapeId(9001), xfrm: Some(Xfrm::new(20.0, 20.0, 920.0, 100.0)), text: Some(body.clone()), ..Default::default() }];
|
||||
}
|
||||
p
|
||||
}
|
||||
|
||||
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
|
||||
#[cfg_attr(not(target_arch = "wasm32"), test)]
|
||||
fn slide_number_field_is_recovered_from_the_page_text_layer() {
|
||||
use deckcraft_model::text::{Run, RunKind};
|
||||
let mut field = Run::new("<#>");
|
||||
field.kind = RunKind::Field { field: "slidenum".into() };
|
||||
let p = deck_with_body(3, ubuntu_runs(vec![Run::new("Page "), field]));
|
||||
let bytes = export(&p, &opts(PageLayout::Slides)).unwrap();
|
||||
for (i, want) in ["Page 1", "Page 2", "Page 3"].into_iter().enumerate() {
|
||||
assert_eq!(page_text(&bytes, i), want);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
|
||||
#[cfg_attr(not(target_arch = "wasm32"), test)]
|
||||
fn line_break_keeps_the_text_layer_aligned_with_the_layout() {
|
||||
use deckcraft_model::text::{Run, RunKind};
|
||||
let mut brk = Run::new("");
|
||||
brk.kind = RunKind::Break;
|
||||
let p = deck_with_body(1, ubuntu_runs(vec![Run::new("one"), brk, Run::new("two")]));
|
||||
let bytes = export(&p, &opts(PageLayout::Slides)).unwrap();
|
||||
assert_eq!(page_text(&bytes, 0), "onetwo");
|
||||
}
|
||||
|
||||
/// Notes pages and handouts print on the chosen paper, landscape included, with every slide box
|
||||
|
||||
@@ -54,7 +54,7 @@ pub use images::{decode as decode_image, gif_frame};
|
||||
pub use morph_path::PathMorph;
|
||||
pub use morph_text::TextMorph;
|
||||
pub use pattern::{is_fg as pattern_is_fg, presets as pattern_presets, tile as pattern_tile};
|
||||
pub use placed::{Placed, PlacedLink, PlacedShape, PlacedText, place_master_texts, place_slide, text_outline};
|
||||
pub use placed::{Placed, PlacedLink, PlacedShape, PlacedText, place_master_texts, place_slide, place_slide_with, text_outline};
|
||||
pub use table::cell_body as table_cell_body;
|
||||
pub use table::cell_rects as table_cell_rects;
|
||||
pub use table::row_heights as table_row_heights;
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
//! that put real text over a raster (PDF text layer, links) and for text extraction.
|
||||
|
||||
use deckcraft_model::resolve::{self, Ctx};
|
||||
use deckcraft_model::text::{Hyperlink, TextBody};
|
||||
use deckcraft_model::text::{Hyperlink, RunKind, TextBody};
|
||||
use deckcraft_model::{Presentation, Shape, ShapeId, ShapeKind};
|
||||
use deckcraft_text::{Opts, TextLayout};
|
||||
use deckcraft_text::{Fields, Opts, TextLayout};
|
||||
use kurbo::{Affine, Rect};
|
||||
|
||||
use crate::{SlideFields, shape_geometry, table};
|
||||
@@ -52,10 +52,16 @@ pub struct Placed {
|
||||
|
||||
/// Text and links of slide `index` (master and layout graphics first, as rendered).
|
||||
pub fn place_slide(pres: &Presentation, index: usize) -> Placed {
|
||||
place_slide_with(pres, index, &crate::RenderOpts::default())
|
||||
}
|
||||
|
||||
/// [`place_slide`] with the render options the slide was drawn with, so date fields take the same
|
||||
/// date and time as the raster (`opts.now`).
|
||||
pub fn place_slide_with(pres: &Presentation, index: usize, opts: &crate::RenderOpts) -> Placed {
|
||||
let mut out = Placed::default();
|
||||
let Some(slide) = pres.slides.get(index) else { return out };
|
||||
let Some(rctx) = Ctx::for_slide(pres, slide) else { return out };
|
||||
let fields = SlideFields::new(pres.slide_number(index), pres.header_footer.footer_text.clone(), &crate::RenderOpts::default());
|
||||
let fields = SlideFields::new(pres.slide_number(index), pres.header_footer.footer_text.clone(), opts);
|
||||
let (show_layout, show_master) = resolve::show_master_shapes(slide, rctx.layout);
|
||||
if show_master {
|
||||
let mctx = Ctx::for_master(pres, rctx.master);
|
||||
@@ -154,9 +160,24 @@ fn place(out: &mut Placed, rctx: &Ctx, fields: &SlideFields, s: &Shape, parent:
|
||||
fn place_text(out: &mut Placed, rctx: &Ctx, fields: &SlideFields, owner: ShapeId, s: &Shape, body: &TextBody, tr: Rect, m: Affine) {
|
||||
let layout = deckcraft_text::layout(rctx, s, body, &Opts { rect: tr, fields, prompt_color: None, no_shrink: false });
|
||||
let m = crate::text_transform(m, tr, layout.rotation);
|
||||
// Field runs carry their saved text (`‹#›`); the layout drew their values, so the body that
|
||||
// goes with it (character offsets, text layer) must hold the same values.
|
||||
let mut placed = body.clone();
|
||||
for (pi, para) in body.paragraphs.iter().enumerate() {
|
||||
for (ri, run) in para.runs.iter().enumerate() {
|
||||
let RunKind::Field { field } = &run.kind else { continue };
|
||||
let lang = resolve::run(rctx, s, para, &run.props).lang;
|
||||
if let Some(v) = fields.field_in(field, lang.as_deref())
|
||||
&& let Some(out_run) = placed.paragraphs.get_mut(pi).and_then(|p| p.runs.get_mut(ri))
|
||||
{
|
||||
out_run.text = v;
|
||||
out_run.kind = RunKind::Text;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Text hyperlinks: one box per line segment of a linked run.
|
||||
for run in layout.runs.iter().filter(|r| r.link && !r.glyphs.is_empty()) {
|
||||
let Some(link) = run_link(body, run.para, run.chars.0) else { continue };
|
||||
let Some(link) = run_link(&placed, run.para, run.chars.0) else { continue };
|
||||
let Some(line) = layout.lines.iter().find(|l| l.para == run.para && l.start <= run.chars.0 && run.chars.0 <= l.end) else { continue };
|
||||
// The run's visual extent (right-to-left runs run from right to left).
|
||||
let first = run.glyphs.first().map(|g| g.1).unwrap_or(0.0);
|
||||
@@ -164,7 +185,7 @@ fn place_text(out: &mut Placed, rctx: &Ctx, fields: &SlideFields, owner: ShapeId
|
||||
let r = Rect::new(x0, line.top, x1, line.bottom);
|
||||
out.links.push(PlacedLink { rect: m.transform_rect_bbox(r), link, shape: owner, text: true });
|
||||
}
|
||||
out.texts.push(PlacedText { layout, transform: m, body: body.clone() });
|
||||
out.texts.push(PlacedText { layout, transform: m, body: placed });
|
||||
}
|
||||
|
||||
/// The hyperlink of the model run containing character `at` of paragraph `para`.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use super::{
|
||||
Ctx, Fields, Fill, Image, Opts, PathMorph, Point, RENDERER, Rect, Region, RenderOpts, Renderer, Rgba, ShapeState, SlideFields, TextMorph,
|
||||
crop_dest, dpi_for_scale, gif_frame, image_dpi, images, morph_path, morph_text, needs_filters, parse_path, pasteboard_region, place_slide,
|
||||
render_blend, render_shapes, render_slide, render_slide_drawn, slide_extent, text_transform,
|
||||
place_slide_with, render_blend, render_shapes, render_slide, render_slide_drawn, slide_extent, text_transform,
|
||||
};
|
||||
use deckcraft_model::chart::{ChartType, Trendline, TrendlineType};
|
||||
use deckcraft_model::style::ColorRef;
|
||||
@@ -2971,6 +2971,27 @@ fn empty_bullet_is_gray_only_while_editing_and_never_in_exports() {
|
||||
assert!(changed.iter().all(|(px, _)| px[0] >= 179 && px[0] == px[1] && px[1] == px[2]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn placed_text_holds_the_field_value_not_the_saved_placeholder() {
|
||||
let mut p = deck_with(vec![field_shape(Some("slidenum"), "<#>")]);
|
||||
let second = p.slides[0].clone();
|
||||
p.slides.push(second);
|
||||
let placed = place_slide(&p, 1);
|
||||
assert_eq!(placed.texts.len(), 1);
|
||||
assert_eq!(placed.texts[0].body.text(), "2");
|
||||
// The layout was drawn from the same value, so the offsets agree with the body.
|
||||
assert_eq!(placed.texts[0].layout.runs.iter().map(|r| r.chars.1).max(), Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn placed_text_takes_its_date_from_the_given_render_options() {
|
||||
use deckcraft_text::datetime::DateTime;
|
||||
let p = deck_with(vec![field_shape(Some("datetime1"), "1/1/2000")]);
|
||||
let at = |now: DateTime| place_slide_with(&p, 0, &RenderOpts { now: Some(now), ..Default::default() });
|
||||
assert_eq!(at(DateTime::new(2026, 10, 9, 20, 5, 7)).texts[0].body.text(), "10/9/2026");
|
||||
assert_eq!(at(DateTime::new(2027, 3, 4, 0, 0, 0)).texts[0].body.text(), "3/4/2027");
|
||||
}
|
||||
|
||||
/// A chart that has no text colour of its own takes PowerPoint's default, the theme's text colour
|
||||
/// at 65 % plus 35 %: light on Nocturne's dark slides (it used to be a fixed grey, near-invisible
|
||||
/// there), 595959 grey on Harbor's light ones.
|
||||
|
||||
Reference in new issue
Block a user