mirror of
https://github.com/storytold/designcraft.git
synced 2026-11-14 14:22:39 +00:00
Merge pull request #2649 from dotCooCoo/fix/merge-image-fields
Data merge: an image field in text places the image, not its path
This commit is contained in:
5 files changed
+443
-50
No files matched your search
@@ -58,7 +58,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Insert Field",
|
||||
[],
|
||||
None,
|
||||
"{field, role?: text|image|qr|hyperlink, story?, at?, end?, item?} → {id}; with the insertion point in a table cell or a note → {cell: true, marker} (a typed marker, not an anchored placeholder)",
|
||||
"{field, role?: text|image|qr|hyperlink, story?, at?, end?, item?} → {id}; an image field at a text insertion point merges as an inline image; with the insertion point in a table cell or a note → {cell: true, marker} (a typed marker, not an anchored placeholder; an image field there is an error unless role is text)",
|
||||
has_doc,
|
||||
placeholder_add
|
||||
),
|
||||
@@ -484,8 +484,13 @@ fn add_placeholder(d: &mut Document, sel: &mut Selection, p: &Value, name: &str)
|
||||
true
|
||||
} else if explicit_story || role_param == Some("text") {
|
||||
false
|
||||
} else if matches!(role_param, Some("image" | "qr")) {
|
||||
true
|
||||
} else if role_param == Some("qr") {
|
||||
// A QR role with the insertion point in text is refused below rather than bound to a
|
||||
// selected frame the caret is not in.
|
||||
sel.text.is_none()
|
||||
} else if role_param == Some("image") {
|
||||
// An image role with the insertion point in text places an inline image there.
|
||||
sel.text.is_none()
|
||||
} else {
|
||||
sel.text.is_none() && !sel.items.is_empty()
|
||||
};
|
||||
@@ -497,6 +502,12 @@ fn add_placeholder(d: &mut Document, sel: &mut Selection, p: &Value, name: &str)
|
||||
DataFieldKind::Qr => PlaceholderRole::Qr,
|
||||
DataFieldKind::Text => return Err(bad("data.placeholder.add", "say which role to use on this frame")),
|
||||
}
|
||||
} else if field.kind == DataFieldKind::Image {
|
||||
// An image field at the insertion point places the image inline in the text.
|
||||
PlaceholderRole::Image
|
||||
} else if field.kind == DataFieldKind::Qr {
|
||||
// A QR field at the insertion point is refused below: QR codes bind to a frame.
|
||||
PlaceholderRole::Qr
|
||||
} else {
|
||||
PlaceholderRole::Text
|
||||
};
|
||||
@@ -515,8 +526,8 @@ fn add_placeholder(d: &mut Document, sel: &mut Selection, p: &Value, name: &str)
|
||||
}
|
||||
PlaceholderAnchor::Item { id: item }
|
||||
} else {
|
||||
if matches!(role, PlaceholderRole::Image | PlaceholderRole::Qr) {
|
||||
return Err(bad("data.placeholder.add", "image and qr placeholders bind to a frame"));
|
||||
if role == PlaceholderRole::Qr {
|
||||
return Err(bad("data.placeholder.add", "qr placeholders bind to a frame"));
|
||||
}
|
||||
// The insertion point is in a table cell or a footnote: the field goes into that text as
|
||||
// its marker, which the merge fills as it fills a typed one. Such a text is a story of its
|
||||
@@ -526,6 +537,14 @@ fn add_placeholder(d: &mut Document, sel: &mut Selection, p: &Value, name: &str)
|
||||
&& let Some(t) = sel.text
|
||||
&& let Some(pressed) = t.cell
|
||||
{
|
||||
// The merge fills such a text with the record's value as text, so an image there
|
||||
// would merge as its path.
|
||||
if role == PlaceholderRole::Image {
|
||||
return Err(bad(
|
||||
"data.placeholder.add",
|
||||
"an image field can't go in a table cell or a note; bind it to a frame, or insert it with role text to merge its path",
|
||||
));
|
||||
}
|
||||
if role != PlaceholderRole::Text {
|
||||
return Err(bad("data.placeholder.add", "a hyperlink field can't go in a table cell or a note; insert it as text"));
|
||||
}
|
||||
@@ -558,7 +577,7 @@ fn add_placeholder(d: &mut Document, sel: &mut Selection, p: &Value, name: &str)
|
||||
if d.story(story).is_none() {
|
||||
return Err(bad("data.placeholder.add", "no story"));
|
||||
}
|
||||
if role == PlaceholderRole::Text {
|
||||
if matches!(role, PlaceholderRole::Text | PlaceholderRole::Image) {
|
||||
let marker = format!("<<{name}>>");
|
||||
let at = {
|
||||
let text = d.story(story).map(|st| st.text.clone()).unwrap_or_default();
|
||||
@@ -568,6 +587,11 @@ fn add_placeholder(d: &mut Document, sel: &mut Selection, p: &Value, name: &str)
|
||||
st.replace(at..at, &marker);
|
||||
}
|
||||
d.adjust_text_ranges(story, at..at, marker.len());
|
||||
// The caret moves past the field, as after typing, so the next field goes after it.
|
||||
if let Some(t) = sel.text.as_mut().filter(|t| t.story == story && t.cell.is_none()) {
|
||||
let shift = |pos: usize| if pos >= at { pos + marker.len() } else { pos };
|
||||
(t.anchor, t.focus) = (shift(t.anchor), shift(t.focus));
|
||||
}
|
||||
PlaceholderAnchor::Text { story, start: at, end: at.saturating_add(marker.len()) }
|
||||
} else {
|
||||
let text = d.story(story).map(|st| st.text.clone()).unwrap_or_default();
|
||||
@@ -610,7 +634,7 @@ pub(crate) fn sync_placeholders(doc: &mut Document) {
|
||||
}
|
||||
PlaceholderAnchor::Text { story, start, end } => {
|
||||
let text = doc.story(story).map(|st| st.text.clone()).unwrap_or_default();
|
||||
if ph.role != PlaceholderRole::Text {
|
||||
if !matches!(ph.role, PlaceholderRole::Text | PlaceholderRole::Image) {
|
||||
if start <= end && end <= text.len() {
|
||||
keep.push(ph);
|
||||
}
|
||||
|
||||
@@ -8,10 +8,10 @@ use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use designcraft_doc::{
|
||||
Asset, AssetId, Content, DataField, Fill, Fitting, Graphic, Hyperlink, HyperlinkDest, HyperlinkSource, Item, ItemId, MergeOptions,
|
||||
PlaceholderAnchor, PlaceholderRole, Shape, SpreadRef, Story, StoryId,
|
||||
AnchorPosition, AnchoredObject, Asset, AssetId, Content, DataField, DataFieldKind, Fill, Fitting, Graphic, Hyperlink, HyperlinkDest,
|
||||
HyperlinkSource, Item, ItemId, MergeOptions, OBJECT_MARK, PlaceholderAnchor, PlaceholderRole, Shape, SpreadRef, Story, StoryId,
|
||||
};
|
||||
use designcraft_geom::{Affine, Rect, Vec2};
|
||||
use designcraft_geom::{Affine, Rect, Vec2, shapes};
|
||||
|
||||
use super::parse::image_candidates;
|
||||
use crate::EngineError;
|
||||
@@ -147,6 +147,16 @@ struct Rep {
|
||||
start: usize,
|
||||
end: usize,
|
||||
text: String,
|
||||
/// An inline image that takes the range's place; `text` is then its object mark.
|
||||
object: Option<AnchoredObject>,
|
||||
}
|
||||
|
||||
/// An image field at a text range: the placed image goes into the text in the marker's place.
|
||||
struct InlineImage {
|
||||
story: StoryId,
|
||||
start: usize,
|
||||
end: usize,
|
||||
field: String,
|
||||
}
|
||||
|
||||
/// Where `fill_row` looks for linked images: the merge options and the two search folders.
|
||||
@@ -199,6 +209,7 @@ impl ImageCache {
|
||||
#[derive(Default)]
|
||||
struct Pending {
|
||||
reps: Vec<Rep>,
|
||||
images: Vec<InlineImage>,
|
||||
links: Vec<(PlaceholderAnchor, HyperlinkDest, String)>,
|
||||
parent: bool,
|
||||
report: FillReport,
|
||||
@@ -225,8 +236,14 @@ pub fn fill_row(
|
||||
PlaceholderAnchor::Item { .. } => plan_item(doc, maps, ph, &cell, record, sources, &mut pending),
|
||||
}
|
||||
}
|
||||
let Pending { mut reps, links, parent, mut report } = pending;
|
||||
typed_reps(doc, fields, row, maps, &placeholders, &mut reps);
|
||||
let Pending { mut reps, mut images, links, parent, mut report } = pending;
|
||||
typed_reps(doc, fields, row, maps, &placeholders, &mut reps, &mut images);
|
||||
for InlineImage { story, start, end, field } in images {
|
||||
let cell = cell_of(fields, row, &field);
|
||||
let object = inline_image(doc, story, &field, cell, record, sources, &mut report);
|
||||
let text = if object.is_some() { OBJECT_MARK.to_string() } else { String::new() };
|
||||
reps.push(Rep { story, start, end, text, object });
|
||||
}
|
||||
apply_reps(doc, &reps);
|
||||
fill_nested_markers(doc, fields, row, maps);
|
||||
for (anchor, dest, field) in links {
|
||||
@@ -262,13 +279,14 @@ fn plan_text(
|
||||
return;
|
||||
};
|
||||
match ph.role {
|
||||
PlaceholderRole::Text => pending.reps.push(Rep { story: mapped, start, end, text: cell }),
|
||||
PlaceholderRole::Text => pending.reps.push(Rep { story: mapped, start, end, text: cell, object: None }),
|
||||
PlaceholderRole::Image => pending.images.push(InlineImage { story: mapped, start, end, field: ph.field.clone() }),
|
||||
PlaceholderRole::Hyperlink => {
|
||||
if let Some(dest) = classify_link(&cell) {
|
||||
pending.links.push((PlaceholderAnchor::Text { story: mapped, start, end }, dest, ph.field.clone()));
|
||||
}
|
||||
}
|
||||
PlaceholderRole::Image | PlaceholderRole::Qr => {}
|
||||
PlaceholderRole::Qr => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -307,6 +325,7 @@ fn typed_reps(
|
||||
maps: &Maps,
|
||||
placeholders: &[designcraft_doc::Placeholder],
|
||||
reps: &mut Vec<Rep>,
|
||||
images: &mut Vec<InlineImage>,
|
||||
) {
|
||||
let mut stories: Vec<(StoryId, StoryId)> = maps.stories.iter().map(|(a, b)| (*a, *b)).collect();
|
||||
stories.sort_by_key(|(a, _)| a.0);
|
||||
@@ -316,7 +335,9 @@ fn typed_reps(
|
||||
let covers: Vec<(usize, usize)> = placeholders
|
||||
.iter()
|
||||
.filter_map(|ph| match (&ph.role, &ph.anchor) {
|
||||
(PlaceholderRole::Text, PlaceholderAnchor::Text { story, start, end }) if *story == original => Some((*start, *end)),
|
||||
(PlaceholderRole::Text | PlaceholderRole::Image, PlaceholderAnchor::Text { story, start, end }) if *story == original => {
|
||||
Some((*start, *end))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
@@ -324,10 +345,12 @@ fn typed_reps(
|
||||
if covers.iter().any(|(a, b)| start < *b && end > *a) {
|
||||
continue;
|
||||
}
|
||||
if !fields.iter().any(|f| f.name == name) {
|
||||
let Some(field) = fields.iter().find(|f| f.name == name) else { continue };
|
||||
if field.kind == DataFieldKind::Image {
|
||||
images.push(InlineImage { story: mapped, start, end, field: name });
|
||||
continue;
|
||||
}
|
||||
reps.push(Rep { story: mapped, start, end, text: cell_of(fields, row, &name).to_string() });
|
||||
reps.push(Rep { story: mapped, start, end, text: cell_of(fields, row, &name).to_string(), object: None });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -437,6 +460,15 @@ fn apply_reps(doc: &mut designcraft_doc::Document, reps: &[Rep]) {
|
||||
for r in mine {
|
||||
if r.start <= r.end && r.end <= st.text.len() {
|
||||
st.replace(r.start..r.end, &r.text);
|
||||
// The replacement made a blank object for its mark; the image takes that slot.
|
||||
if let Some(obj) = &r.object
|
||||
&& st.text.get(r.start..).is_some_and(|t| t.starts_with(OBJECT_MARK))
|
||||
{
|
||||
let k = st.text[..r.start].matches(OBJECT_MARK).count();
|
||||
if let Some(slot) = st.objects.get_mut(k) {
|
||||
*slot = Arc::new(obj.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -490,10 +522,19 @@ fn create_link(doc: &mut designcraft_doc::Document, anchor: PlaceholderAnchor, d
|
||||
true
|
||||
}
|
||||
|
||||
fn place_image(doc: &mut designcraft_doc::Document, item: ItemId, field: &str, cell: &str, record: u32, sources: &Sources, report: &mut FillReport) {
|
||||
let Sources { options, data_dir, doc_dir, images } = *sources;
|
||||
/// An image file named by a cell: where it was found, its bytes, its pixel size and its natural size.
|
||||
struct LoadedImage {
|
||||
path: PathBuf,
|
||||
bytes: Arc<Vec<u8>>,
|
||||
pixels: (u32, u32),
|
||||
natural: (f64, f64),
|
||||
}
|
||||
|
||||
/// The first candidate for `cell` that reads and decodes; `None` when none does.
|
||||
fn load_image(cell: &str, sources: &Sources) -> Option<LoadedImage> {
|
||||
let Sources { data_dir, doc_dir, images, .. } = *sources;
|
||||
let cands = image_candidates(cell, data_dir, doc_dir);
|
||||
let found = cands.iter().find_map(|p| {
|
||||
cands.iter().find_map(|p| {
|
||||
// Read once per merge: many records naming one file is the normal case, and reading it
|
||||
// per record is the amplification (#1595).
|
||||
let bytes = images.get_or_read(p, || {
|
||||
@@ -504,24 +545,100 @@ fn place_image(doc: &mut designcraft_doc::Document, item: ItemId, field: &str, c
|
||||
}
|
||||
crate::io::read_file(p).ok().filter(|b| !b.is_empty())
|
||||
})?;
|
||||
Some((p.clone(), bytes))
|
||||
// A file that does not decode leaves the search going, so a valid image at a later
|
||||
// candidate is still found.
|
||||
let (pixels, natural) = designcraft_render::image_size(&bytes).zip(designcraft_render::natural_size(&bytes, 0))?;
|
||||
Some(LoadedImage { path: p.clone(), bytes, pixels, natural })
|
||||
})
|
||||
}
|
||||
|
||||
fn report_missing(report: &mut FillReport, record: u32, field: &str, cell: &str) {
|
||||
report.missing.push(MissingImage { record, field: field.to_string(), path: cell.to_string() });
|
||||
report.warnings.push(format!("Record {record}, field {field}: missing image {cell}."));
|
||||
}
|
||||
|
||||
/// Add `image` to the document as a new asset, linked to its file when the options say so.
|
||||
fn add_image_asset(doc: &mut designcraft_doc::Document, image: LoadedImage, field: &str, options: &MergeOptions) -> AssetId {
|
||||
let fresh = AssetId(doc.alloc());
|
||||
let name = image.path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| field.to_string());
|
||||
let link = options.link_images.then(|| image.path.to_string_lossy().into_owned());
|
||||
doc.add_asset(Asset {
|
||||
id: fresh,
|
||||
name,
|
||||
mime: designcraft_render::image_mime(&image.bytes).to_string(),
|
||||
link,
|
||||
// The cached buffer is shared with every other record naming this file, rather than
|
||||
// copied per record (#1595).
|
||||
data: image.bytes,
|
||||
pixels: Some(image.pixels),
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
/// The image `cell` names, as an inline object for the text of `story`. It comes in at its
|
||||
/// natural size, scaled down to fit the smallest text column of the story's frames (never scaled up).
|
||||
fn inline_image(
|
||||
doc: &mut designcraft_doc::Document,
|
||||
story: StoryId,
|
||||
field: &str,
|
||||
cell: &str,
|
||||
record: u32,
|
||||
sources: &Sources,
|
||||
report: &mut FillReport,
|
||||
) -> Option<AnchoredObject> {
|
||||
let Some(image) = load_image(cell, sources) else {
|
||||
report_missing(report, record, field, cell);
|
||||
return None;
|
||||
};
|
||||
let (nw, nh) = image.natural;
|
||||
let frame = doc.story(story).and_then(|st| st.frames.first().copied()).and_then(|id| doc.item(id));
|
||||
let layer = frame.map_or_else(|| doc.default_layer(), |it| it.layer);
|
||||
// The marker may flow into any of the story's frames: fit the smallest column among them.
|
||||
let fit = designcraft_compose::frame_specs(doc, story)
|
||||
.iter()
|
||||
.flat_map(|f| f.columns())
|
||||
.map(|b| (b.width() / nw).min(b.height() / nh))
|
||||
.reduce(f64::min)
|
||||
.unwrap_or(1.0);
|
||||
let k = if fit.is_finite() && fit > 0.0 { fit.min(1.0) } else { 1.0 };
|
||||
let rect = Rect::new(0.0, 0.0, nw * k, nh * k);
|
||||
let xf = Affine::scale(k);
|
||||
if super::super::file::validate_placement((nw, nh), rect, xf, "data.merge").is_err() {
|
||||
report.missing.push(MissingImage { record, field: field.to_string(), path: cell.to_string() });
|
||||
report.warnings.push(format!("Record {record}, field {field}: image {cell} exceeds finite geometry limits."));
|
||||
return None;
|
||||
}
|
||||
let asset = add_image_asset(doc, image, field, sources.options);
|
||||
let mut it = Item::new(ItemId(doc.alloc()), layer, Shape::Rectangle, shapes::rectangle(rect));
|
||||
it.object_style = doc.styles.default_graphic_frame.clone();
|
||||
if let Some(e) = doc.styles.object_effects(&it.object_style) {
|
||||
it.effects = e.clone();
|
||||
}
|
||||
it.content = Content::Graphic(Graphic {
|
||||
layer_options: None,
|
||||
asset,
|
||||
size: (nw, nh),
|
||||
xf,
|
||||
auto_fit: Fitting::None,
|
||||
fit_align: 0,
|
||||
crop: [0.0; 4],
|
||||
wrap: Default::default(),
|
||||
clip: None,
|
||||
fill: Default::default(),
|
||||
});
|
||||
let Some((path, bytes)) = found else {
|
||||
Some(AnchoredObject::new(it, AnchorPosition::default()))
|
||||
}
|
||||
|
||||
fn place_image(doc: &mut designcraft_doc::Document, item: ItemId, field: &str, cell: &str, record: u32, sources: &Sources, report: &mut FillReport) {
|
||||
let options = sources.options;
|
||||
let Some(image) = load_image(cell, sources) else {
|
||||
if let Some(it) = doc.item_mut(item) {
|
||||
it.content = Content::Unassigned;
|
||||
}
|
||||
report.missing.push(MissingImage { record, field: field.to_string(), path: cell.to_string() });
|
||||
report.warnings.push(format!("Record {record}, field {field}: missing image {cell}."));
|
||||
return;
|
||||
};
|
||||
let Some(((pw, ph), (nw, nh))) = designcraft_render::image_size(&bytes).zip(designcraft_render::natural_size(&bytes, 0)) else {
|
||||
if let Some(it) = doc.item_mut(item) {
|
||||
it.content = Content::Unassigned;
|
||||
}
|
||||
report.missing.push(MissingImage { record, field: field.to_string(), path: cell.to_string() });
|
||||
report.warnings.push(format!("Record {record}, field {field}: missing image {cell}."));
|
||||
report_missing(report, record, field, cell);
|
||||
return;
|
||||
};
|
||||
let (nw, nh) = image.natural;
|
||||
let Some(inner) = doc.item(item).map(|it| it.inner_bounds()) else {
|
||||
report.warnings.push(format!("Record {record}, field {field}: missing image {cell}."));
|
||||
report.missing.push(MissingImage { record, field: field.to_string(), path: cell.to_string() });
|
||||
@@ -558,20 +675,7 @@ fn place_image(doc: &mut designcraft_doc::Document, item: ItemId, field: &str, c
|
||||
report.warnings.push(format!("Record {record}, field {field}: image {cell} exceeds finite geometry limits."));
|
||||
return;
|
||||
}
|
||||
let fresh = AssetId(doc.alloc());
|
||||
let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| field.to_string());
|
||||
let link = options.link_images.then(|| path.to_string_lossy().into_owned());
|
||||
g.asset = doc.add_asset(Asset {
|
||||
id: fresh,
|
||||
name,
|
||||
mime: designcraft_render::image_mime(&bytes).to_string(),
|
||||
link,
|
||||
// The cached buffer is shared with every other record naming this file, rather than
|
||||
// copied per record (#1595).
|
||||
data: bytes,
|
||||
pixels: Some((pw, ph)),
|
||||
..Default::default()
|
||||
});
|
||||
g.asset = add_image_asset(doc, image, field, options);
|
||||
if let Some(it) = doc.item_mut(item) {
|
||||
it.content = Content::Graphic(g);
|
||||
}
|
||||
@@ -732,4 +836,15 @@ mod image_path_tests {
|
||||
let found = image_candidates("img/x.png", Some(data), Some(doc));
|
||||
assert_eq!(found, [data.join("img").join("x.png"), doc.join("img").join("x.png")]);
|
||||
}
|
||||
|
||||
/// A path that starts with one separator is tried as it is, then beside the data file and the
|
||||
/// document without the separator. Two separators still name another computer.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_rooted_image_cell_is_also_tried_beside_the_data_and_the_document() {
|
||||
let (data, doc) = (Path::new("/work/data"), Path::new("/work/doc"));
|
||||
let found = image_candidates("/img/x.png", Some(data), Some(doc));
|
||||
assert_eq!(found, [Path::new("/img/x.png").to_path_buf(), data.join("img").join("x.png"), doc.join("img").join("x.png")]);
|
||||
assert!(image_candidates("//192.0.2.1/img/x.png", Some(data), Some(doc)).is_empty());
|
||||
}
|
||||
}
|
||||
@@ -297,15 +297,18 @@ fn next_slot(col: i32, row: i32, rows_first: bool, fits_col: &impl Fn(i32) -> bo
|
||||
/// Files to try for an image cell: an absolute path as it is, a relative one beside the data
|
||||
/// file, then beside the document. Relative paths are not resolved against the process working
|
||||
/// directory, and paths that may not be followed ([`crate::io::document_path`]: URLs, other
|
||||
/// computers, devices) are not candidates.
|
||||
/// computers, devices) are not candidates. A path that starts with one separator (`\Photos\x.jpg`)
|
||||
/// is tried as it is first, then beside the data file and the document with that separator dropped.
|
||||
pub fn image_candidates(cell: &str, data_dir: Option<&Path>, doc_dir: Option<&Path>) -> Vec<PathBuf> {
|
||||
let cell = cell.trim();
|
||||
if cell.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let below = cell.strip_prefix(['\\', '/']).filter(|rest| !rest.is_empty() && !rest.starts_with(['\\', '/']));
|
||||
let found = std::iter::once(crate::io::document_path(cell, None))
|
||||
.chain(data_dir.map(|dir| crate::io::folder_path(cell, dir)))
|
||||
.chain(doc_dir.map(|dir| crate::io::folder_path(cell, dir)));
|
||||
.chain(doc_dir.map(|dir| crate::io::folder_path(cell, dir)))
|
||||
.chain(below.into_iter().flat_map(|rest| [data_dir, doc_dir].into_iter().flatten().map(move |dir| crate::io::folder_path(rest, dir))));
|
||||
let mut out: Vec<PathBuf> = Vec::new();
|
||||
for p in found.flatten() {
|
||||
if !out.contains(&p) {
|
||||
|
||||
@@ -749,6 +749,231 @@ fn role_overrides_column_kind_qr_and_hyperlink() {
|
||||
assert_eq!(kinds, vec!["email:a@b.c".to_string(), "url:https://example.com".to_string()]);
|
||||
}
|
||||
|
||||
fn write_png_sized(path: &Path, width: u32, height: u32) {
|
||||
let pixels = vec![255; width as usize * height as usize * 4];
|
||||
let bytes = designcraft_render::Rendered { width, height, pixels }.to_png();
|
||||
assert_eq!(designcraft_render::image_size(&bytes), Some((width, height)), "the fixture png must decode");
|
||||
if let Some(dir) = path.parent() {
|
||||
std::fs::create_dir_all(dir).unwrap();
|
||||
}
|
||||
std::fs::write(path, bytes).unwrap();
|
||||
}
|
||||
|
||||
/// The images anchored in the stories of document `index`, as (file name, width, height), by name.
|
||||
fn inline_images(s: &Session, index: usize) -> Vec<(String, f64, f64)> {
|
||||
let doc = &s.documents()[index].doc;
|
||||
let mut out = Vec::new();
|
||||
for st in doc.stories.values() {
|
||||
for o in &st.objects {
|
||||
if let Content::Graphic(g) = &o.item.content {
|
||||
let name = doc.assets.get(&g.asset).map(|a| a.name.clone()).unwrap_or_default();
|
||||
let (w, h) = o.size();
|
||||
out.push((name, w, h));
|
||||
}
|
||||
}
|
||||
}
|
||||
out.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
out
|
||||
}
|
||||
|
||||
/// An image field inserted at a text insertion point placed the path as text. It places the
|
||||
/// image in the text: at its natural size, scaled down to fit the frame, with a missing file
|
||||
/// reported and its marker removed.
|
||||
#[test]
|
||||
fn an_image_field_at_the_insertion_point_merges_as_an_inline_image() {
|
||||
let dir = scratch("inline-image");
|
||||
let data = dir.join("data");
|
||||
write_png_sized(&data.join("pics").join("small.png"), 40, 20);
|
||||
write_png_sized(&data.join("pics").join("wide.png"), 800, 100);
|
||||
write(&data.join("cards.csv"), "Name,@Photo\nAda,pics/small.png\nGrace,pics/wide.png\nKai,pics/none.png\n");
|
||||
|
||||
let mut s = Session::new();
|
||||
new_doc(&mut s, 1);
|
||||
exec(&mut s, "data.source.select", json!({"path": data.join("cards.csv")}));
|
||||
let made = text_frame(&mut s, [36.0, 36.0, 236.0, 236.0], "Photo: ", None);
|
||||
exec(&mut s, "text.select", json!({"story": made["story"], "anchor": 7, "focus": 7}));
|
||||
exec(&mut s, "data.placeholder.add", json!({"field": "Photo"}));
|
||||
let template = &s.documents()[0].doc;
|
||||
assert_eq!(template.stories.values().next().unwrap().text, "Photo: <<Photo>>");
|
||||
let width = template.item(designcraft_doc::ItemId(made["id"].as_u64().unwrap())).unwrap().inner_bounds().width();
|
||||
|
||||
exec(&mut s, "data.preview", json!({"record": 1}));
|
||||
assert_eq!(inline_images(&s, 0).len(), 1, "the preview shows the image");
|
||||
exec(&mut s, "data.preview.stop", json!({}));
|
||||
|
||||
let r = exec(&mut s, "data.merge", json!({}));
|
||||
assert_eq!(r["records"], 3);
|
||||
let missing = r["missingImages"].as_array().unwrap();
|
||||
assert_eq!(missing.len(), 1, "{missing:?}");
|
||||
assert_eq!((missing[0]["record"].as_u64(), missing[0]["path"].as_str()), (Some(3), Some("pics/none.png")));
|
||||
let mark = designcraft_doc::OBJECT_MARK;
|
||||
assert_eq!(texts(&s), vec!["Photo: ".to_string(), format!("Photo: {mark}"), format!("Photo: {mark}")]);
|
||||
let placed = inline_images(&s, s.active_index().unwrap());
|
||||
assert_eq!(placed.len(), 2, "{placed:?}");
|
||||
let (small, wide) = (&placed[0], &placed[1]);
|
||||
assert_eq!(small.0, "small.png");
|
||||
assert!((small.1 - 40.0).abs() < 0.01 && (small.2 - 20.0).abs() < 0.01, "a small image keeps its natural size: {small:?}");
|
||||
assert_eq!(wide.0, "wide.png");
|
||||
assert!((wide.1 - width).abs() < 0.01 && (wide.2 - width / 8.0).abs() < 0.01, "a wide image is scaled down to the frame: {wide:?}");
|
||||
}
|
||||
|
||||
/// A QR field at a text insertion point is refused instead of merging its payload as text.
|
||||
#[test]
|
||||
fn a_qr_field_at_the_insertion_point_is_refused() {
|
||||
let mut s = Session::new();
|
||||
new_doc(&mut s, 1);
|
||||
select_csv(&mut s, "@Photo,#Code\n/x.png,hello\n");
|
||||
let made = text_frame(&mut s, [36.0, 36.0, 300.0, 300.0], "Hi ", None);
|
||||
exec(&mut s, "text.select", json!({"story": made["story"], "anchor": 3, "focus": 3}));
|
||||
let msg = err(&mut s, "data.placeholder.add", json!({"field": "Code"}));
|
||||
assert!(msg.contains("bind to a frame"), "{msg}");
|
||||
assert_eq!(texts(&s), vec!["Hi ".to_string()]);
|
||||
assert!(s.documents()[0].doc.data_merge.placeholders.is_empty());
|
||||
}
|
||||
|
||||
/// An explicit QR role at a text insertion point is refused too, rather than binding the QR code
|
||||
/// to a selected frame the caret is not in.
|
||||
#[test]
|
||||
fn an_explicit_qr_role_at_the_insertion_point_is_refused() {
|
||||
let mut s = Session::new();
|
||||
new_doc(&mut s, 1);
|
||||
select_csv(&mut s, "Name,#Code\nAda,hello\n");
|
||||
let made = text_frame(&mut s, [36.0, 36.0, 300.0, 300.0], "Hi ", None);
|
||||
let other = text_frame(&mut s, [36.0, 400.0, 300.0, 600.0], "", None);
|
||||
let other_id = designcraft_doc::ItemId(other["id"].as_u64().unwrap());
|
||||
let sid = StoryId(made["story"].as_u64().unwrap());
|
||||
let doc = s.doc_mut().unwrap();
|
||||
doc.selection = Selection::text(TextSel { story: sid, anchor: 3, focus: 3, frame: None, cell: None });
|
||||
doc.selection.items = vec![other_id];
|
||||
let msg = err(&mut s, "data.placeholder.add", json!({"field": "Code", "role": "qr"}));
|
||||
assert!(msg.contains("bind to a frame"), "{msg}");
|
||||
let doc = &s.documents()[0].doc;
|
||||
assert!(doc.data_merge.placeholders.is_empty());
|
||||
assert_eq!(doc.item(other_id).unwrap().label, "", "the other frame is not bound");
|
||||
}
|
||||
|
||||
/// Fields inserted one after another at the insertion point keep their order: the caret moves
|
||||
/// past each marker, as after typing.
|
||||
#[test]
|
||||
fn fields_inserted_at_the_insertion_point_keep_their_order() {
|
||||
let mut s = Session::new();
|
||||
new_doc(&mut s, 1);
|
||||
select_csv(&mut s, "Name,City,@Photo\nAda,Paris,/x.png\n");
|
||||
let made = text_frame(&mut s, [36.0, 36.0, 300.0, 300.0], "Hi ", None);
|
||||
exec(&mut s, "text.select", json!({"story": made["story"], "anchor": 3, "focus": 3}));
|
||||
exec(&mut s, "data.placeholder.add", json!({"field": "Name", "role": "text"}));
|
||||
exec(&mut s, "data.placeholder.add", json!({"field": "City", "role": "text"}));
|
||||
exec(&mut s, "data.placeholder.add", json!({"field": "Photo"}));
|
||||
assert_eq!(texts(&s), vec!["Hi <<Name>><<City>><<Photo>>".to_string()]);
|
||||
let t = s.documents()[0].selection.text.unwrap();
|
||||
assert_eq!((t.anchor, t.focus), (28, 28));
|
||||
}
|
||||
|
||||
/// A typed `<<name>>` marker for an image column places the image too, from an absolute path;
|
||||
/// an explicit text role still writes the path as text.
|
||||
#[test]
|
||||
fn a_typed_image_marker_places_the_image_and_a_text_role_keeps_the_path() {
|
||||
let dir = scratch("typed-image");
|
||||
let pic = dir.join("pic.png");
|
||||
write_png_sized(&pic, 10, 30);
|
||||
let mut s = Session::new();
|
||||
new_doc(&mut s, 1);
|
||||
select_csv(&mut s, &format!("@Photo\n{}\n", pic.display()));
|
||||
text_frame(&mut s, [36.0, 36.0, 300.0, 300.0], "A <<Photo>> B", None);
|
||||
let r = exec(&mut s, "data.merge", json!({}));
|
||||
assert_eq!(r["missingImages"].as_array().map(Vec::len), Some(0));
|
||||
assert_eq!(texts(&s), vec![format!("A {} B", designcraft_doc::OBJECT_MARK)]);
|
||||
let placed = inline_images(&s, s.active_index().unwrap());
|
||||
assert_eq!(placed.len(), 1);
|
||||
assert!((placed[0].1 - 10.0).abs() < 0.01 && (placed[0].2 - 30.0).abs() < 0.01, "{placed:?}");
|
||||
|
||||
let mut s = Session::new();
|
||||
new_doc(&mut s, 1);
|
||||
select_csv(&mut s, &format!("@Photo\n{}\n", pic.display()));
|
||||
let made = text_frame(&mut s, [36.0, 36.0, 300.0, 300.0], "", None);
|
||||
exec(&mut s, "text.select", json!({"story": made["story"], "anchor": 0, "focus": 0}));
|
||||
exec(&mut s, "data.placeholder.add", json!({"field": "Photo", "role": "text"}));
|
||||
exec(&mut s, "data.merge", json!({}));
|
||||
assert_eq!(texts(&s), vec![pic.display().to_string()]);
|
||||
assert!(inline_images(&s, s.active_index().unwrap()).is_empty());
|
||||
}
|
||||
|
||||
/// An explicit image role at a text insertion point places an inline image too, and in a frame
|
||||
/// with two columns the image is scaled down to one column's width.
|
||||
#[test]
|
||||
fn an_inline_image_role_at_text_fits_one_column() {
|
||||
let dir = scratch("inline-columns");
|
||||
let pic = dir.join("wide.png");
|
||||
write_png_sized(&pic, 800, 100);
|
||||
let mut s = Session::new();
|
||||
new_doc(&mut s, 1);
|
||||
select_csv(&mut s, &format!("@Photo\n{}\n", pic.display()));
|
||||
let made = text_frame(&mut s, [36.0, 36.0, 456.0, 336.0], "", None);
|
||||
exec(&mut s, "object.textFrameOptions", json!({"ids": [made["id"]], "columns": 2, "gutter": 20}));
|
||||
exec(&mut s, "text.select", json!({"story": made["story"], "anchor": 0, "focus": 0}));
|
||||
exec(&mut s, "data.placeholder.add", json!({"field": "Photo", "role": "image"}));
|
||||
let r = exec(&mut s, "data.merge", json!({}));
|
||||
assert_eq!(r["missingImages"].as_array().map(Vec::len), Some(0), "{r}");
|
||||
let placed = inline_images(&s, s.active_index().unwrap());
|
||||
assert_eq!(placed.len(), 1, "{placed:?}");
|
||||
// 420 pt wide, less the 20 pt gutter, gives two 200 pt columns.
|
||||
assert!((placed[0].1 - 200.0).abs() < 0.01 && (placed[0].2 - 25.0).abs() < 0.01, "{placed:?}");
|
||||
|
||||
// The story threads on into a 100 pt frame: the image fits that one, wherever the marker lands.
|
||||
let mut s = Session::new();
|
||||
new_doc(&mut s, 1);
|
||||
select_csv(&mut s, &format!("@Photo\n{}\n", pic.display()));
|
||||
let made = text_frame(&mut s, [36.0, 36.0, 456.0, 336.0], "", None);
|
||||
let small = exec(&mut s, "frame.create", json!({"rect": [36.0, 400.0, 136.0, 500.0], "content": "unassigned"}));
|
||||
exec(&mut s, "text.thread", json!({"from": made["id"], "to": small["id"]}));
|
||||
exec(&mut s, "text.select", json!({"story": made["story"], "anchor": 0, "focus": 0}));
|
||||
exec(&mut s, "data.placeholder.add", json!({"field": "Photo"}));
|
||||
exec(&mut s, "data.merge", json!({}));
|
||||
let placed = inline_images(&s, s.active_index().unwrap());
|
||||
assert_eq!(placed.len(), 1, "{placed:?}");
|
||||
assert!(placed[0].1 <= 100.0 + 0.01, "{placed:?}");
|
||||
}
|
||||
|
||||
/// A cell that starts with one separator (`\pics\a.png`) and names no file from the drive's root
|
||||
/// is found beside the data file.
|
||||
#[test]
|
||||
fn an_image_path_with_a_leading_separator_is_found_beside_the_data_file() {
|
||||
let dir = scratch("rooted-image");
|
||||
let data = dir.join("data");
|
||||
write_png_sized(&data.join("dc-merge-rooted-pics").join("a.png"), 12, 12);
|
||||
write(&data.join("cards.csv"), "@Photo\n\\dc-merge-rooted-pics\\a.png\n/dc-merge-rooted-pics/a.png\n");
|
||||
let mut s = Session::new();
|
||||
new_doc(&mut s, 1);
|
||||
exec(&mut s, "data.source.select", json!({"path": data.join("cards.csv")}));
|
||||
let frame = exec(&mut s, "frame.create", json!({"rect": [36.0, 36.0, 80.0, 80.0], "content": "graphic"}));
|
||||
exec(&mut s, "data.placeholder.add", json!({"field": "Photo", "item": frame["id"]}));
|
||||
let r = exec(&mut s, "data.merge", json!({}));
|
||||
assert_eq!(r["records"], 2);
|
||||
assert_eq!(r["missingImages"].as_array().map(Vec::len), Some(0), "{r}");
|
||||
}
|
||||
|
||||
/// A file at an earlier candidate that is not an image does not hide a valid image at a later
|
||||
/// one: the search goes on past it.
|
||||
#[test]
|
||||
fn a_non_image_file_beside_the_data_does_not_hide_the_image_beside_the_document() {
|
||||
let dir = scratch("undecodable-image");
|
||||
let (data, docs) = (dir.join("data"), dir.join("docs"));
|
||||
write(&data.join("pics").join("a.png"), "not an image");
|
||||
write_png_sized(&docs.join("pics").join("a.png"), 12, 12);
|
||||
write(&data.join("cards.csv"), "@Photo\npics/a.png\n");
|
||||
let mut s = Session::new();
|
||||
new_doc(&mut s, 1);
|
||||
exec(&mut s, "data.source.select", json!({"path": data.join("cards.csv")}));
|
||||
exec(&mut s, "file.save", json!({"path": docs.join("layout.designcraft")}));
|
||||
let frame = exec(&mut s, "frame.create", json!({"rect": [36.0, 36.0, 80.0, 80.0], "content": "graphic"}));
|
||||
exec(&mut s, "data.placeholder.add", json!({"field": "Photo", "item": frame["id"]}));
|
||||
exec(&mut s, "data.options", json!({"linkImages": true}));
|
||||
let r = exec(&mut s, "data.merge", json!({}));
|
||||
assert_eq!(r["missingImages"].as_array().map(Vec::len), Some(0), "{r}");
|
||||
let link = graphic_link(&s).unwrap_or_default();
|
||||
assert!(link.contains("docs"), "{link}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_record_merge_copies_pages_and_leaves_the_template() {
|
||||
let mut s = Session::new();
|
||||
@@ -1418,6 +1643,25 @@ fn a_field_named_with_angle_brackets_is_refused_in_a_cell() {
|
||||
let _ = made;
|
||||
}
|
||||
|
||||
/// An image field with the insertion point in a table cell is refused with a message about image
|
||||
/// fields, and nothing is inserted; with role text its marker goes into the cell.
|
||||
#[test]
|
||||
fn an_image_field_in_a_cell_is_refused_unless_its_role_is_text() {
|
||||
let mut s = Session::new();
|
||||
table_with_caret_in_cell(&mut s, "Name,@Photo\nAda,pics/a.png\n");
|
||||
for params in [json!({"field": "Photo"}), json!({"field": "Photo", "role": "image"})] {
|
||||
let e = s.execute("data.placeholder.add", ¶ms).expect_err("refused").to_string();
|
||||
assert!(e.contains("an image field can't go in a table cell or a note"), "{e}");
|
||||
assert!(!e.contains("hyperlink"), "{e}");
|
||||
assert_eq!(cell_texts(&s), ["Who", "Where"], "nothing inserted");
|
||||
}
|
||||
let r = exec(&mut s, "data.placeholder.add", json!({"field": "Photo", "role": "text"}));
|
||||
assert_eq!(r["marker"], "<<Photo>>", "{r}");
|
||||
assert_eq!(cell_texts(&s), ["Who", "<<Photo>>Where"]);
|
||||
exec(&mut s, "data.preview", json!({"record": 1}));
|
||||
assert_eq!(cell_texts(&s), ["Who", "pics/a.pngWhere"], "the text role merges the path");
|
||||
}
|
||||
|
||||
/// Endnote text is filled too: it is a text inside its story, like a footnote's.
|
||||
#[test]
|
||||
fn a_field_in_an_endnote_is_filled() {
|
||||
|
||||
+9
-2
@@ -88,8 +88,15 @@ external PDF/X certification.
|
||||
|
||||
`data.source.select {path, sheet?}` links a CSV/TSV/`.xlsx` file to the document (`data.source.update` re-reads it,
|
||||
`data.source.remove` drops it); `data.fields` lists its fields. `data.placeholder.add {field, role?, story?, at?, end?, item?}`
|
||||
marks text or a frame as a field (`role`: text, image, qr, hyperlink); `data.options` sets records, tiling and image
|
||||
fitting; `data.preview {record}` / `data.preview.stop` show one record without saving it.
|
||||
marks text or a frame as a field (`role`: text, image, qr, hyperlink). An image field (a `@` column) inserted at a text
|
||||
insertion point, or typed there as `<<name>>`, merges as an inline image at its natural size, scaled down to fit the
|
||||
frame. The merge writes only text into a table cell or a note: an image field inserted there is refused unless `role`
|
||||
is `text`, and its `<<name>>` marker, inserted or typed, merges as the path. A QR field (a `#` column) or `role: "qr"`
|
||||
at a text insertion point is refused; bind it to a frame with `item` instead. A field inserted at the insertion point
|
||||
puts the caret after its marker. An image
|
||||
path is absolute or relative to the data file's folder, then the document's; a path that starts with one separator
|
||||
(`\Photos\a.jpg`) is tried from the drive's root first. `data.options` sets records, tiling and image fitting;
|
||||
`data.preview {record}` / `data.preview.stop` show one record without saving it.
|
||||
|
||||
`data.merge` now **creates a new document** and leaves the template unchanged (the old `spread` parameter, which
|
||||
appended pages to the template, is an error). It uses the linked source, or inline `csv` / `rows` / `path` / `bytes`:
|
||||
|
||||
Reference in new issue
Block a user