Add craft-flow: the flow model the text formats map to (#1025)

* 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.
This commit is contained in:
Dylan M. Taylor authored and GitHub committed 2026-11-11 20:41:34 -05:00
1 parent 336619e3e4
commit baf6bd58f0
13 files changed
+4108

No files matched your search

Generated
+9
View File
@@ -1166,6 +1166,15 @@ dependencies = [
"thiserror 2.0.21",
]
[[package]]
name = "craft-flow"
version = "0.1.0"
dependencies = [
"craft-wordmodel",
"image",
"log",
]
[[package]]
name = "craft-font-catalog"
version = "0.1.0"
+1
View File
@@ -50,6 +50,7 @@ craft-font-catalog = { path = "crates/font-catalog" }
craft-font-manager = { path = "crates/font-manager" }
craft-font-store = { path = "crates/font-store" }
craft-gainmap = { path = "crates/gainmap" }
craft-flow = { path = "crates/flow" }
craft-gcode = { path = "crates/gcode" }
craft-gif = { path = "crates/gif" }
craft-hdr = { path = "crates/hdr" }
+1
View File
@@ -106,6 +106,7 @@ craft-<name> = { git = "https://github.com/storytold/craft-libs", rev = "<commit
and channel names, the channels that make a colour picture, and Cryptomatte IDs, metadata keys,
manifests and rank channels. The optional `decode` feature adds bounded part listing, flat
decoding (scan lines, tiles, every compression of the `exr` crate) and writing.
- [`craft-flow`](crates/flow/README.md): the flow model every text format maps to (paragraphs with a kind, lists and formatted spans; tables; pictures, fields, notes, equations), converted to and from a craft-wordmodel `Document`.
- [`craft-font-catalog`](crates/font-catalog/README.md): the installed fonts on every platform
(font folders, fontconfig and Flatpak host folders, CoreText, DirectWrite, the font store),
lookup by family, style, localized, legacy, full and PostScript names, family loads shared
+17
View File
@@ -0,0 +1,17 @@
[package]
name = "craft-flow"
description = "The flow model every text format maps to: paragraphs with a kind, list membership and formatted spans, tables of cells, pictures, notes and fields; converted to and from a craft-wordmodel Document."
version.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true
repository.workspace = true
[lints]
workspace = true
[dependencies]
craft-wordmodel = { workspace = true }
log = "0.4"
# Picture formats and pixel sizes, read from the headers.
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "bmp"] }
+31
View File
@@ -0,0 +1,31 @@
# craft-flow
The model every text format maps to and from: a `Flow` is blocks, a block a paragraph (`Para`:
a `Kind` — normal, heading, title, quote, code line, rule — list membership, alignment, spacing
and formatted `Inline` spans: text, pictures, links, bookmarks, fields, notes, equations, ruby,
charts, shapes) or a table (`FTable` of `Cell`s holding blocks), with the document's `Meta`.
`to_doc` and `from_doc` convert a flow to and from a `craft_wordmodel::Document`. A format reader
builds a flow and calls `to_doc`; a writer calls `from_doc` (or `from_doc_with` and `FlowOpts` for
what that format keeps: fields, boxes, paragraph formatting, tracked changes) and walks the flow.
Formats that keep more than the flow holds (ODT) carry it in the optional fields (`extra`,
`kept`, …), which the plain formats leave empty.
```rust
use craft_flow::{FBlock, Flow, Fmt, Inline, Kind, Para, from_doc, to_doc};
let p = Para { kind: Kind::Heading(1), inlines: vec![Inline::Text("Title".into(), Fmt::default())], ..Default::default() };
let doc = to_doc(&Flow { blocks: vec![FBlock::Para(p)], ..Default::default() });
let back = from_doc(&doc);
assert!(matches!(&back.blocks[..], [FBlock::Para(p)] if p.kind == Kind::Heading(1) && p.text() == "Title"));
```
Also here: picture sniffing and sizing (`sniff_image`, `image_px`, `make_img`), CSS colours
(`parse_color`), base64 and `data:` URIs, list labels (`ListCounter`), and the one-warning-per-kind
report of what an importer leaves out to stay within a limit (`Dropped`, `begin_import`,
`dropped`).
## Provenance
WordCraft's `crates/formats/src/model` (at `07f97737`), moved as it is onto craft-wordmodel.
Image decoding for re-encoding (`decode_bounded`) stays with the RTF writer that needs it.
+196
View File
@@ -0,0 +1,196 @@
use super::Rgb;
fn hsl_to_rgb(h: f64, s: f64, l: f64) -> Rgb {
let h = (h / 360.0).rem_euclid(1.0);
let s = s.clamp(0.0, 1.0);
let l = l.clamp(0.0, 1.0);
if s == 0.0 {
let v = (l * 255.0).round().clamp(0.0, 255.0) as u8;
return Rgb(v, v, v);
}
let q = if l < 0.5 { l * (1.0 + s) } else { l + s - l * s };
let p = 2.0 * l - q;
let hue_to_rgb = |mut t: f64| -> f64 {
if t < 0.0 {
t += 1.0;
}
if t > 1.0 {
t -= 1.0;
}
if t < 1.0 / 6.0 {
return p + (q - p) * 6.0 * t;
}
if t < 1.0 / 2.0 {
return q;
}
if t < 2.0 / 3.0 {
return p + (q - p) * (2.0 / 3.0 - t) * 6.0;
}
p
};
let r = (hue_to_rgb(h + 1.0 / 3.0) * 255.0).round().clamp(0.0, 255.0) as u8;
let g = (hue_to_rgb(h) * 255.0).round().clamp(0.0, 255.0) as u8;
let b = (hue_to_rgb(h - 1.0 / 3.0) * 255.0).round().clamp(0.0, 255.0) as u8;
Rgb(r, g, b)
}
/// Composite `rgb` over the white page a paste lands on, at CSS alpha `a` (0..1): `rgba(0, 0, 0,
/// 0.45)` reads like the rgb(140, 140, 140) it looks like on the site, instead of solid black.
fn over_white(r: u8, g: u8, b: u8, a: f32) -> Rgb {
let mix = |c: u8| (c as f32 * a + 255.0 * (1.0 - a)).round().clamp(0.0, 255.0) as u8;
Rgb(mix(r), mix(g), mix(b))
}
/// The alpha of a CSS colour's `/ alpha` or 4th-value part, where present: 1.0 (opaque) otherwise.
fn css_alpha(after: Option<&str>) -> f32 {
let Some(a) = after else { return 1.0 };
let a = a.trim();
// CSS Colour 4's `none` is a missing component, and a missing alpha is transparent.
if a.eq_ignore_ascii_case("none") {
return 0.0;
}
// A value that is not a finite number (`nan`, `inf`) is no alpha at all: clamp would keep
// NaN, and `over_white` would turn the colour black.
let parse = |t: &str| t.parse::<f32>().ok().filter(|f| f.is_finite());
if let Some(p) = a.strip_suffix('%') {
return parse(p.trim()).map(|f| f.clamp(0.0, 100.0) / 100.0).unwrap_or(1.0);
}
parse(a).map(|f| f.clamp(0.0, 1.0)).unwrap_or(1.0)
}
/// Parse a CSS / HTML colour: `#rgb`, `#rgba`, `#rrggbb`, `#rrggbbaa`, `rgb(...)`, `hsl(...)` or a basic name.
/// A colour with alpha is composited over white: the model keeps opaque colours, and a paste lands
/// on a white page.
pub fn parse_color(s: &str) -> Option<Rgb> {
let s = s.trim().to_ascii_lowercase();
if let Some(h) = s.strip_prefix('#') {
if (h.len() == 3 || h.len() == 4) && h.is_ascii() {
let d = |i: usize| h.get(i..i + 1).and_then(|x| u8::from_str_radix(x, 16).ok()).map(|v| v * 17);
let (r, g, b) = (d(0)?, d(1)?, d(2)?);
// A 4th digit is the alpha, composited over the page like `rgba(...)`'s is.
let a = if h.len() == 4 { d(3)? as f32 / 255.0 } else { 1.0 };
return Some(over_white(r, g, b, a));
}
if h.len() == 8 && h.bytes().all(|b| b.is_ascii_hexdigit()) {
let p = |i: usize| h.get(i..i + 2).and_then(|x| u8::from_str_radix(x, 16).ok());
return Some(over_white(p(0)?, p(2)?, p(4)?, p(6)? as f32 / 255.0));
}
return Rgb::parse(h);
}
if let Some(inner) = s.strip_prefix("rgb(").or_else(|| s.strip_prefix("rgba(")) {
let inner = inner.trim_end_matches(')').trim();
let (rgb_part, alpha_part) = inner.split_once('/').map_or((inner, None), |(a, b)| (a, Some(b)));
// Legacy comma syntax carries the alpha as a 4th value (`rgba(0, 0, 0, 0.45)`).
let parts: Vec<&str> = if rgb_part.contains(',') { rgb_part.split(',').collect() } else { rgb_part.split_whitespace().collect() };
let alpha = match alpha_part {
Some(a) => css_alpha(Some(a)),
None if parts.len() >= 4 => css_alpha(Some(parts[3])),
_ => 1.0,
};
let v: Vec<u8> = parts
.iter()
.take(3)
.filter_map(|x| {
let x = x.trim();
if let Some(p) = x.strip_suffix('%') {
p.trim().parse::<f32>().ok().map(|f| (f.clamp(0.0, 100.0) * 2.55).round() as u8)
} else {
x.parse::<f32>().ok().map(|f| f.clamp(0.0, 255.0).round() as u8)
}
})
.collect();
if let [r, g, b] = v[..] {
return Some(over_white(r, g, b, alpha));
}
return None;
}
if let Some(inner) = s.strip_prefix("hsl(").or_else(|| s.strip_prefix("hsla(")) {
let inner = inner.trim_end_matches(')').trim();
let (hsl_part, alpha_part) = inner.split_once('/').map_or((inner, None), |(a, b)| (a, Some(b)));
let parts: Vec<&str> = if hsl_part.contains(',') { hsl_part.split(',').collect() } else { hsl_part.split_whitespace().collect() };
let alpha = match alpha_part {
Some(a) => css_alpha(Some(a)),
None if parts.len() >= 4 => css_alpha(Some(parts[3])),
_ => 1.0,
};
if parts.len() < 3 {
return None;
}
let h_str = parts[0].trim();
let h: f64 = if let Some(deg) = h_str.strip_suffix("deg") {
deg.trim().parse().ok()?
} else if let Some(rad) = h_str.strip_suffix("rad") {
rad.trim().parse::<f64>().ok()? * 180.0 / std::f64::consts::PI
} else if let Some(turn) = h_str.strip_suffix("turn") {
turn.trim().parse::<f64>().ok()? * 360.0
} else {
h_str.parse().ok()?
};
let parse_pct = |x: &str| -> Option<f64> {
let x = x.trim();
if let Some(p) = x.strip_suffix('%') {
Some((p.trim().parse::<f64>().ok()? / 100.0).clamp(0.0, 1.0))
} else {
Some(x.parse::<f64>().ok()?.clamp(0.0, 1.0))
}
};
let s = parse_pct(parts[1])?;
let l = parse_pct(parts[2])?;
let Rgb(r, g, b) = hsl_to_rgb(h, s, l);
return Some(over_white(r, g, b, alpha));
}
Some(match s.as_str() {
"black" => Rgb(0, 0, 0),
"white" => Rgb(255, 255, 255),
"red" => Rgb(255, 0, 0),
"green" => Rgb(0, 128, 0),
"lime" => Rgb(0, 255, 0),
"blue" => Rgb(0, 0, 255),
"yellow" => Rgb(255, 255, 0),
"cyan" | "aqua" => Rgb(0, 255, 255),
"magenta" | "fuchsia" => Rgb(255, 0, 255),
"gray" | "grey" => Rgb(128, 128, 128),
"silver" => Rgb(192, 192, 192),
"maroon" => Rgb(128, 0, 0),
"olive" => Rgb(128, 128, 0),
"navy" => Rgb(0, 0, 128),
"purple" => Rgb(128, 0, 128),
"teal" => Rgb(0, 128, 128),
"orange" => Rgb(255, 165, 0),
_ => return None,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn eight_digit_hex_rejects_signs() {
assert_eq!(parse_color("#+1+2+3+4"), None);
// #11223344 is alpha 0x44 over white.
assert_eq!(parse_color("#11223344"), Some(Rgb(192, 196, 201)));
}
#[test]
fn colors_with_alpha_composite_over_white() {
// The secondary text colour of dark-theme sites: solid black would read as the site's
// own emphasis, white is invisible on the page; the composite is what the eye saw.
assert_eq!(parse_color("rgba(0, 0, 0, 0.45)"), Some(Rgb(140, 140, 140)));
assert_eq!(parse_color("rgb(0 0 0 / 0.45)"), Some(Rgb(140, 140, 140)));
assert_eq!(parse_color("rgba(0, 0, 0, 0)"), Some(Rgb(255, 255, 255)));
assert_eq!(parse_color("rgba(255, 0, 0, 1)"), Some(Rgb(255, 0, 0)));
assert_eq!(parse_color("rgba(255, 0, 0, 45%)"), Some(Rgb(255, 140, 140)));
assert_eq!(parse_color("hsla(120, 100%, 50%, 0.5)"), Some(Rgb(128, 255, 128)));
// A 4-digit hex's last digit is the alpha, too: #0008 is black at 8/15 over white.
assert_eq!(parse_color("#0008"), Some(Rgb(119, 119, 119)));
// `none` is a missing alpha, and a missing alpha is transparent, not the opaque default
// an omitted alpha takes.
assert_eq!(parse_color("rgb(0 0 0 / none)"), Some(Rgb(255, 255, 255)));
assert_eq!(parse_color("rgb(0 0 0)"), Some(Rgb(0, 0, 0)));
// An alpha that is not a finite number is no alpha: the colour stays opaque, not black.
assert_eq!(parse_color("rgba(0, 0, 0, nan)"), Some(Rgb(0, 0, 0)));
assert_eq!(parse_color("rgba(0, 0, 0, inf)"), Some(Rgb(0, 0, 0)));
assert_eq!(parse_color("rgba(0, 0, 0, nan%)"), Some(Rgb(0, 0, 0)));
}
}
File diff suppressed because it is too large. Load diff
+187
View File
@@ -0,0 +1,187 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
// The code came from an app whose lints allow this.
#![allow(clippy::type_complexity)]
//! A small "flow" model every text format maps to and from: paragraphs with a kind (heading,
//! quote, code…), list membership, alignment and formatted inline spans; tables of cells holding
//! blocks. [`to_doc`] and [`from_doc`] convert between it and the full [`Document`].
#[cfg(doctest)]
#[doc = include_str!("../README.md")]
struct ReadmeExamples;
use std::sync::Arc;
use craft_wordmodel::numbering::{AbstractNum, Level, ListKind, Num, levels_for};
use craft_wordmodel::para::{InlineObject, OBJ, PAGE_BREAK, RubyAlign, Run, ruby_raise};
use craft_wordmodel::props::{
Border, BorderStyle, Borders, CellProps, CharProps, NumRef, ParaProps, PropChange, RowProps, TextColor, Underline, VAlign, VMerge, VertAlign,
};
use craft_wordmodel::section::NumFormat;
use craft_wordmodel::styles::{Style, StyleKind};
use craft_wordmodel::{Align, Block, Blocks, Document, Paragraph, Revision, RevisionKind, Rgb, Table, para_block};
mod color;
mod from_doc;
mod media;
pub mod odf_object;
mod to_doc;
mod types;
pub use color::parse_color;
pub use from_doc::{
ListCounter, flow_paras, from_doc, from_doc_tracked, from_doc_with, is_custom_char_style, is_custom_style, is_mono, para_kind, part_flow,
tabs_from_indent,
};
pub use media::{base64_decode, base64_encode, data_uri, image_px, make_img, mime_of, percent_decode, sized_img, sniff_image};
pub use to_doc::{char_props, part_blocks, to_doc, to_doc_in};
pub use types::{
Affix, BoxLook, CODE_STYLE, Cell, CellPic, Chain, Chart, Conn, FBlock, FComment, FTable, Flow, FlowOpts, Fmt, FmtChange, Group, Img, Inline,
Kind, Label, ListInfo, MAX_DEPTH, MAX_FONT_NAME, MAX_GROUP_DEPTH, MAX_LINK, MONO_FONT, Member, Meta, OdfObject, Para, RUBY_BASE_SIZE, RevMark,
Spacing, TableExtra, clean_text, equations_as_text, flatten_tables, kept_font, kept_link, ranged_anchors, ruby_as_text, ruby_object,
shows_nothing,
};
pub use types::{Dropped, MAX_TABLE_CELLS, begin_import, dropped};
thread_local! {
/// The kinds of [`Dropped`] already reported for the document being imported on this thread.
/// Per thread because a document is parsed and built on one, and the parsers drop things
/// from places that have no log of their own to hand.
static REPORTED: std::cell::Cell<u8> = const { std::cell::Cell::new(0) };
}
#[cfg(test)]
mod tests {
use super::*;
/// ASK and SET show nothing (layout shows no result for them): no text export writes the
/// result they hold, from the document or from a flow with fields; ODT keeps it.
#[test]
fn ask_and_set_fields_export_no_text() {
let mut doc = craft_wordmodel::Document::from_text("Before after");
if let Ok(p) = doc.para_mut(craft_wordmodel::StoryRef::Body, &craft_wordmodel::Path::top(0)) {
for instr in ["SET name other", "ASK other \"Prompt\""] {
let field = craft_wordmodel::para::InlineObject::Field {
instr: instr.into(),
result: "SECRETVALUE".into(),
locked: false,
dirty: false,
code: false,
form: String::new(),
};
p.insert_object(7, field, &Default::default()).unwrap();
}
}
let flow = from_doc_with(&doc, FlowOpts { fields: true, ..Default::default() });
// The flow keeps the result for ODT.
let kept = flow
.blocks
.iter()
.any(|b| matches!(b, FBlock::Para(p) if p.inlines.iter().any(|i| matches!(i, Inline::Field { result, .. } if result == "SECRETVALUE"))));
assert!(kept);
}
#[test]
fn percent_decode_requires_hex_digits() {
assert_eq!(percent_decode("%+A"), b"%+A".to_vec());
assert_eq!(percent_decode("%-A"), b"%-A".to_vec());
assert_eq!(percent_decode("%G1"), b"%G1".to_vec());
assert_eq!(percent_decode("%41"), b"A".to_vec());
}
#[test]
fn insert_covered_is_bounded_on_hostile_rowspan() {
// Many rows each carrying the maximum rowspan would be O(rows × rowspan) covered cells;
// the work cap must keep insertion bounded (and quick) instead of exhausting memory.
let mut t = FTable {
rows: (0..20_000).map(|_| vec![Cell { rowspan: 1000, ..Default::default() }]).collect(),
widths: Vec::new(),
borderless: false,
extra: None,
kept: None,
wrap: None,
};
t.insert_covered();
assert!(t.rows.iter().all(|r| r.len() <= 64), "a row grew past the column cap");
}
#[test]
fn base64_round_trip() {
for data in [&b""[..], b"f", b"fo", b"foo", b"foob", b"fooba", b"foobar", &[0, 255, 128, 7]] {
let e = base64_encode(data);
assert_eq!(base64_decode(&e).unwrap(), data, "{e}");
}
assert_eq!(base64_encode(b"foobar"), "Zm9vYmFy");
assert!(base64_decode("@@").is_none());
assert_eq!(data_uri("data:text/plain,a%20b").unwrap(), b"a b");
}
#[test]
fn colors() {
assert_eq!(parse_color("#f00"), Some(Rgb(255, 0, 0)));
assert_eq!(parse_color("#f00f"), Some(Rgb(255, 0, 0)));
assert_eq!(parse_color("#0080FF"), Some(Rgb(0, 128, 255)));
assert_eq!(parse_color("#0080FFff"), Some(Rgb(0, 128, 255)));
// A colour with alpha is composited over the white page it lands on (#0080ff at 50%).
assert_eq!(parse_color("#0080ff80"), Some(Rgb(127, 191, 255)));
assert_eq!(parse_color("rgb(1, 2, 3)"), Some(Rgb(1, 2, 3)));
assert_eq!(parse_color("rgb(1 2 3)"), Some(Rgb(1, 2, 3)));
assert_eq!(parse_color("rgb(1 2 3 / 50%)"), Some(Rgb(128, 129, 129)));
assert_eq!(parse_color("hsl(0, 100%, 50%)"), Some(Rgb(255, 0, 0)));
assert_eq!(parse_color("hsl(120deg 100% 50%)"), Some(Rgb(0, 255, 0)));
assert_eq!(parse_color("hsla(240, 100%, 50%, 0.8)"), Some(Rgb(51, 51, 255)));
assert_eq!(parse_color("navy"), Some(Rgb(0, 0, 128)));
assert_eq!(parse_color("#zz"), None);
assert_eq!(parse_color("rgb(1)"), None);
}
#[test]
fn list_labels() {
let mut c = ListCounter::default();
let o = |level| ListInfo { ordered: true, level, start: 1, ..Default::default() };
assert_eq!(c.label(o(0)), "1.");
assert_eq!(c.label(o(1)), "a.");
assert_eq!(c.label(o(1)), "b.");
assert_eq!(c.label(o(0)), "2.");
assert_eq!(c.label(ListInfo { ordered: false, level: 0, start: 1, ..Default::default() }), "•");
}
#[test]
fn covered_cells_inserted() {
let mut t = FTable {
rows: vec![vec![Cell { rowspan: 2, ..Default::default() }, Cell::default()], vec![Cell::default()]],
widths: vec![],
borderless: false,
extra: None,
kept: None,
wrap: None,
};
t.insert_covered();
assert_eq!(t.rows[1].len(), 2);
assert!(t.rows[1][0].covered);
}
#[test]
fn trim_drops_a_long_run_of_blank_spans_in_linear_time() {
// Separately formatted line breaks don't merge; each used to be removed with
// `Vec::remove(0)`, shifting all the rest (quadratic).
let mut p = Para::default();
let (b, i) = (Fmt { bold: true, ..Default::default() }, Fmt { italic: true, ..Default::default() });
for _ in 0..200_000 {
p.push_text("\n", &b);
p.push_text(" ", &i);
}
p.push_text(" x ", &Fmt::default());
let t = std::time::Instant::now();
p.trim();
// A fraction of a second; `Vec::remove(0)` per span ran past 6 minutes.
assert!(t.elapsed() < std::time::Duration::from_secs(10), "{:?}", t.elapsed());
assert_eq!(p.inlines, vec![Inline::Text("x".into(), Fmt::default())]);
// The first span left keeps its formatting and loses only its leading blanks.
let mut p = Para::default();
p.push_text(" \t", &b);
p.push_text(" a ", &i);
p.push_text("b ", &b);
p.trim();
assert_eq!(p.inlines, vec![Inline::Text("a ".into(), i), Inline::Text("b".into(), b)]);
}
}
+147
View File
@@ -0,0 +1,147 @@
use super::Arc;
use super::types::Img;
// ---------------------------------------------------------------------------------------------
// Shared helpers
/// Image extension from bytes, if it's a format we can display.
pub fn sniff_image(data: &[u8]) -> Option<&'static str> {
match image::guess_format(data).ok()? {
image::ImageFormat::Png => Some("png"),
image::ImageFormat::Jpeg => Some("jpeg"),
image::ImageFormat::Gif => Some("gif"),
image::ImageFormat::WebP => Some("webp"),
image::ImageFormat::Bmp => Some("bmp"),
_ => None,
}
}
/// Pixel size of encoded image bytes.
pub fn image_px(data: &[u8]) -> Option<(u32, u32)> {
image::ImageReader::new(std::io::Cursor::new(data)).with_guessed_format().ok()?.into_dimensions().ok()
}
/// A picture from bytes with an optional display size (points); the natural size (96 dpi) is
/// used otherwise, shrunk to fit a 6.5" column.
pub fn make_img(data: impl Into<Arc<Vec<u8>>>, w: Option<f32>, h: Option<f32>, alt: &str) -> Option<Img> {
let data = data.into();
let ext = sniff_image(&data)?;
let natural = image_px(&data).map(|(a, b)| (a as f32 * 0.75, b as f32 * 0.75));
Some(sized_img(data, ext, natural, w, h, alt))
}
/// A picture of a known format `ext` whose natural size (points) the caller knows, if at all: a
/// metafile, which `image` can't measure. Sized like [`make_img`].
pub fn sized_img(data: Arc<Vec<u8>>, ext: &str, natural: Option<(f32, f32)>, w: Option<f32>, h: Option<f32>, alt: &str) -> Img {
let (pw, ph) = natural.filter(|(a, b)| a.is_finite() && b.is_finite()).unwrap_or((72.0, 72.0));
let (pw, ph) = (pw.max(1.0), ph.max(1.0));
let (mut w, mut h) = match (w.filter(|v| v.is_finite() && *v > 0.0), h.filter(|v| v.is_finite() && *v > 0.0)) {
(Some(w), Some(h)) => (w, h),
(Some(w), None) => (w, w * ph / pw),
(None, Some(h)) => (h * pw / ph, h),
(None, None) => (pw, ph),
};
if w > 468.0 {
h *= 468.0 / w;
w = 468.0;
}
Img {
data,
ext: ext.to_string(),
w: w.clamp(1.0, 1584.0),
h: h.clamp(1.0, 1584.0),
alt: alt.to_string(),
float: None,
object: None,
crop: [0.0; 4],
}
}
const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
pub fn base64_encode(data: &[u8]) -> String {
let mut out = String::with_capacity(data.len().div_ceil(3) * 4);
for ch in data.chunks(3) {
let b = [ch.first().copied().unwrap_or(0), ch.get(1).copied().unwrap_or(0), ch.get(2).copied().unwrap_or(0)];
let n = (u32::from(b[0]) << 16) | (u32::from(b[1]) << 8) | u32::from(b[2]);
let idx = [(n >> 18) & 63, (n >> 12) & 63, (n >> 6) & 63, n & 63];
for (i, v) in idx.iter().enumerate() {
if i <= ch.len() {
out.push(B64.get(*v as usize).copied().unwrap_or(b'A') as char);
} else {
out.push('=');
}
}
}
out
}
/// Lenient base64 (standard or URL-safe alphabet; whitespace and padding ignored).
pub fn base64_decode(s: &str) -> Option<Vec<u8>> {
let mut out = Vec::with_capacity(s.len() / 4 * 3);
let mut acc = 0u32;
let mut bits = 0u32;
for c in s.bytes() {
let v = match c {
b'A'..=b'Z' => c - b'A',
b'a'..=b'z' => c - b'a' + 26,
b'0'..=b'9' => c - b'0' + 52,
b'+' | b'-' => 62,
b'/' | b'_' => 63,
b'=' => break,
c if c.is_ascii_whitespace() => continue,
_ => return None,
};
acc = (acc << 6) | u32::from(v);
bits += 6;
if bits >= 8 {
bits -= 8;
out.push((acc >> bits) as u8);
acc &= (1 << bits) - 1;
}
}
Some(out)
}
/// Decode a `data:` URI (base64 or percent-encoded).
pub fn data_uri(uri: &str) -> Option<Vec<u8>> {
let rest = uri.trim().strip_prefix("data:")?;
let (head, data) = rest.split_once(',')?;
if head.ends_with(";base64") { base64_decode(data) } else { Some(percent_decode(data)) }
}
pub fn percent_decode(s: &str) -> Vec<u8> {
let b = s.as_bytes();
let mut out = Vec::with_capacity(b.len());
let mut i = 0;
while let Some(&c) = b.get(i) {
if c == b'%'
&& let Some(h) = b.get(i + 1..i + 3)
&& h.iter().all(u8::is_ascii_hexdigit)
&& let Some(hex) = std::str::from_utf8(h).ok()
&& let Ok(v) = u8::from_str_radix(hex, 16)
{
out.push(v);
i += 3;
continue;
}
out.push(c);
i += 1;
}
out
}
pub fn mime_of(ext: &str) -> &'static str {
match ext {
"png" => "image/png",
"jpg" | "jpeg" => "image/jpeg",
"gif" => "image/gif",
"webp" => "image/webp",
"bmp" => "image/bmp",
"svg" => "image/svg+xml",
"ico" => "image/x-icon",
"wmf" => "image/x-wmf",
"emf" => "image/x-emf",
_ => "application/octet-stream",
}
}
+72
View File
@@ -0,0 +1,72 @@
//! ODF embedded objects ([`OdfObject`]) in a document: their files in
//! [`Document::passthrough`] under `odf-object/<n>/`, linked from the picture showing them by an
//! [`Embedded`] whose markup only names the object's media type, and taken back out for the ODT
//! writer. Other formats see the picture alone.
use std::sync::Arc;
use craft_wordmodel::Document;
use craft_wordmodel::graphic::{Embedded, EmbeddedRef};
use super::types::OdfObject;
/// Relationship kind of an object's file, and of its replacement picture.
const FILE: &str = "file";
const REPLACEMENT: &str = "replacement";
/// Most objects a document keeps, and most files in one.
pub const MAX_OBJECTS: usize = 1000;
pub const MAX_FILES: usize = 256;
fn esc(s: &str) -> String {
s.replace('&', "&amp;").replace('"', "&quot;").replace('<', "&lt;").replace('>', "&gt;")
}
fn unesc(s: &str) -> String {
s.replace("&quot;", "\"").replace("&lt;", "<").replace("&gt;", ">").replace("&amp;", "&")
}
/// Keep `obj` in `doc`: its files as passthrough parts, and the link to them. `None` when the
/// document already holds as many objects as it keeps.
pub fn store(doc: &mut Document, obj: &OdfObject) -> Option<Arc<Embedded>> {
let taken = |n: usize| {
let prefix = format!("odf-object/{n}/");
doc.passthrough.range(prefix.clone()..).next().is_some_and(|(k, _)| k.starts_with(&prefix))
};
let n = (0..MAX_OBJECTS).find(|&n| !taken(n))?;
let mut refs = Vec::new();
for (k, (path, bytes)) in obj.files.iter().take(MAX_FILES).enumerate() {
let target = format!("odf-object/{n}/{k}");
doc.passthrough.insert(target.clone(), bytes.clone());
refs.push(EmbeddedRef { id: path.clone(), kind: FILE.into(), target, external: false });
}
if let Some(r) = &obj.replacement {
let target = format!("odf-object/{n}/replacement");
doc.passthrough.insert(target.clone(), r.clone());
refs.push(EmbeddedRef { id: String::new(), kind: REPLACEMENT.into(), target, external: false });
}
let xml = format!("{} mime=\"{}\" ole=\"{}\"/>", Embedded::ODF_OBJECT, esc(&obj.mime), obj.ole);
Some(Arc::new(Embedded { xml, refs }))
}
/// The object `e` links to in `doc`, when it is an ODF object whose files are all there.
pub fn load(doc: &Document, e: &Embedded) -> Option<Arc<OdfObject>> {
if !e.is_odf_object() {
return None;
}
let attr = |k: &str| {
let pat = format!(" {k}=\"");
let from = e.xml.find(&pat)? + pat.len();
let len = e.xml.get(from..)?.find('"')?;
e.xml.get(from..from + len).map(unesc)
};
let mut obj = OdfObject { mime: attr("mime").unwrap_or_default(), ole: attr("ole").as_deref() == Some("true"), ..Default::default() };
for r in e.refs.iter().take(MAX_FILES + 1) {
let bytes = doc.passthrough.get(&r.target)?.clone();
match r.kind.as_str() {
FILE => obj.files.push((r.id.clone(), bytes)),
REPLACEMENT => obj.replacement = Some(bytes),
_ => {}
}
}
(!obj.files.is_empty()).then(|| Arc::new(obj))
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+101
View File
@@ -0,0 +1,101 @@
//! 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));
}