mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-14 22:42:46 +00:00
* Add craft-wordmodel: WordCraft's document model without its editing code WordCraft's crates/doc (wordcraft-doc, at 07f97737) moved as it is, so the DOCX, RTF, ODT and other text-format crates can read into and write from one model. Module paths, type names and serde attributes are unchanged: an app that re-exports the crate keeps its native file format. Left in the app: range editing (edit.rs), IME text and equation editing. Not model, and already in craft-libs: code pages (craft-rtf), the ZIP entry-count check (craft-opc), the compound-file guard (craft-cfb). The paragraph walks moved to walk.rs and the unit helpers of wordcraft-geom to geom.rs. * craft-wordmodel: math_tree, the arguments of an equation node and a walk over its runs The DOCX reader and writer walk an equation's runs. The walk and the child accessors it uses were in WordCraft's equation editor (math_edit.rs), which stays in the app; they are structure, not editing. * craft-wordmodel: keep the document operations (edit.rs) with the model The DOCX and RTF readers use them: alternative-format chunks are merged into a document with adopt_blocks, and a reader formats ranges with format_range and inserts objects. They operate on the document, not on a view, so they belong beside it. The tests that drove them come back with them; the paragraph walks stay in walk.rs and edit re-exports them. base64 0.23, as WordCraft and craft-offcrypto use. * Add craft-flow: the flow model the text formats map to, on craft-wordmodel WordCraft's crates/formats/src/model (07f97737) moved as it is: Flow, FBlock, Para, Inline, Fmt, tables and cells, pictures, to_doc and from_doc, picture sniffing, colours, base64 and the Dropped report. Only the paths changed (wordcraft_doc is craft_wordmodel); the report helpers are public so the format crates can call them; the image re-encoding helper stays with WordCraft's RTF writer.
102 lines
4.6 KiB
Rust
102 lines
4.6 KiB
Rust
//! A flow made into a document and read back keeps what the text formats express: kinds,
|
||
//! lists, alignment, character formatting, links, tables, pictures and the metadata.
|
||
|
||
use craft_flow::{Cell, FBlock, FTable, Flow, Fmt, Inline, Kind, ListInfo, Meta, Para, from_doc, make_img, to_doc};
|
||
use craft_wordmodel::Align;
|
||
|
||
/// A 1×1 PNG.
|
||
const PNG: &[u8] = &[
|
||
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0, 0, 0, 0x0D, 0x49, 0x48, 0x44, 0x52, 0, 0, 0, 1, 0, 0, 0, 1, 8, 6, 0, 0, 0, 0x1F, 0x15, 0xC4,
|
||
0x89, 0, 0, 0, 0x0D, 0x49, 0x44, 0x41, 0x54, 0x78, 0xDA, 0x63, 0xFC, 0xCF, 0xC0, 0x50, 0x0F, 0, 0x04, 0x85, 0x01, 0x80, 0x84, 0xA9, 0x8C, 0x21,
|
||
0, 0, 0, 0, 0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82,
|
||
];
|
||
|
||
fn para(kind: Kind, inlines: Vec<Inline>) -> Para {
|
||
Para { kind, inlines, ..Default::default() }
|
||
}
|
||
|
||
fn text(s: &str, f: Fmt) -> Inline {
|
||
Inline::Text(s.into(), f)
|
||
}
|
||
|
||
fn sample() -> Flow {
|
||
let bold = Fmt { bold: true, ..Default::default() };
|
||
let link = Fmt { link: Some("https://example.com/x".into()), ..Default::default() };
|
||
let mut centred =
|
||
para(Kind::Normal, vec![text("Centred ", Fmt::default()), text("bold", bold.clone()), text(" and ", Fmt::default()), text("a link", link)]);
|
||
centred.align = Some(Align::Center);
|
||
let item = |s: &str, ordered: bool, level: u8| Para {
|
||
list: Some(ListInfo { ordered, level, ..Default::default() }),
|
||
..para(Kind::Normal, vec![text(s, Fmt::default())])
|
||
};
|
||
let cell = |s: &str, header: bool| Cell {
|
||
blocks: vec![FBlock::Para(para(Kind::Normal, vec![text(s, Fmt::default())]))],
|
||
colspan: 1,
|
||
rowspan: 1,
|
||
header,
|
||
..Default::default()
|
||
};
|
||
let table = FTable { rows: vec![vec![cell("A", true), cell("B", true)], vec![cell("1", false), cell("2", false)]], ..Default::default() };
|
||
let img = make_img(PNG.to_vec(), Some(36.0), None, "tiny").map(Inline::Image).into_iter().collect();
|
||
Flow {
|
||
blocks: vec![
|
||
FBlock::Para(para(Kind::Heading(1), vec![text("Main heading", Fmt::default())])),
|
||
FBlock::Para(centred),
|
||
FBlock::Para(item("first", false, 0)),
|
||
FBlock::Para(item("nested", true, 1)),
|
||
FBlock::Para(para(Kind::Quote, vec![text("Quoted.", Fmt::default())])),
|
||
FBlock::Para(para(Kind::Code, vec![text("let x = 1;", Fmt { code: true, ..Default::default() })])),
|
||
FBlock::Table(table),
|
||
FBlock::Para(para(Kind::Normal, img)),
|
||
],
|
||
meta: Meta { title: "Sample".into(), author: "Ada".into(), ..Default::default() },
|
||
}
|
||
}
|
||
|
||
fn paras(blocks: &[FBlock]) -> Vec<&Para> {
|
||
blocks.iter().filter_map(|b| if let FBlock::Para(p) = b { Some(p) } else { None }).collect()
|
||
}
|
||
|
||
#[test]
|
||
fn a_flow_survives_a_document() {
|
||
let flow = sample();
|
||
let doc = to_doc(&flow);
|
||
assert_eq!((doc.core.title.as_str(), doc.core.creator.as_str()), ("Sample", "Ada"));
|
||
assert_eq!(doc.media.len(), 1, "the picture is in the document");
|
||
let back = from_doc(&doc);
|
||
let (want, got) = (paras(&flow.blocks), paras(&back.blocks));
|
||
assert_eq!(got.len(), want.len());
|
||
for (w, g) in want.iter().zip(&got) {
|
||
assert_eq!((g.kind, g.list.map(|l| (l.ordered, l.level)), g.align), (w.kind, w.list.map(|l| (l.ordered, l.level)), w.align), "{}", w.text());
|
||
assert_eq!(g.text(), w.text());
|
||
}
|
||
let fmt_of = |p: &Para, s: &str| {
|
||
p.inlines.iter().find_map(|i| {
|
||
if let Inline::Text(t, f) = i
|
||
&& t == s
|
||
{
|
||
Some(f.clone())
|
||
} else {
|
||
None
|
||
}
|
||
})
|
||
};
|
||
assert!(fmt_of(got[1], "bold").is_some_and(|f| f.bold));
|
||
assert_eq!(fmt_of(got[1], "a link").and_then(|f| f.link).as_deref(), Some("https://example.com/x"));
|
||
assert!(matches!(got[6].inlines.as_slice(), [Inline::Image(i)] if i.alt == "tiny" && (i.w - 36.0).abs() < 0.01));
|
||
let Some(FBlock::Table(t)) = back.blocks.iter().find(|b| matches!(b, FBlock::Table(_))) else { panic!("no table") };
|
||
assert_eq!(t.rows.len(), 2);
|
||
assert_eq!(t.rows[1].iter().map(Cell::text).collect::<Vec<_>>(), ["1", "2"]);
|
||
}
|
||
|
||
#[test]
|
||
fn pictures_are_sniffed_and_sized_from_their_headers() {
|
||
assert_eq!(craft_flow::sniff_image(PNG), Some("png"));
|
||
assert_eq!(craft_flow::image_px(PNG), Some((1, 1)));
|
||
assert_eq!(craft_flow::sniff_image(b"not a picture"), None);
|
||
assert!(make_img(b"not a picture".to_vec(), None, None, "").is_none());
|
||
// A picture wider than the column is shrunk to it, keeping its proportions.
|
||
let wide = make_img(PNG.to_vec(), Some(1000.0), Some(500.0), "").unwrap();
|
||
assert_eq!((wide.w, wide.h), (468.0, 234.0));
|
||
}
|