Files
vectorcraft/crates/engine/src/cmd/fileio/text.rs
T
bflatasticandClaude Opus 5.5 da18cf4234 M4.50: WebP options, Export As Text and Save for Office Documents
- WebP takes lossless and quality; there is no permissive pure-Rust lossy encoder yet, so
  lossless: false writes lossless with a warning (the WebP Options dialog says so).
- document.export {format: "txt", encoding: utf8|utf16, lineEndings: lf|crlf,
  selectionOnly}: the stories in stacking order (back to front), a thread once, hidden
  objects and template layers left out; UTF-16 is little-endian with a byte order mark.
  Export As → Text opens Text Export Options.
- File → Save for Office Documents… (document.exportForOffice {ppi, transparent, artboard,
  path}): the artboard as a PNG at 72/150/300 ppi on white or transparent, through a small
  dialog in the app.
document.formats' writable list and the MCP export tool's enum gain txt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 11:27:22 +07:00

97 lines
3.8 KiB
Rust

//! Export As Text (`.txt`): the document's stories, one after another in stacking order (back to
//! front), as UTF-8 or UTF-16 with LF or CRLF line endings. Threaded frames are one story,
//! written where its first frame is; hidden objects and template layers are left out.
use std::collections::HashSet;
use serde::Deserialize;
use serde_json::Value;
use vectorcraft_doc::{Document, Node, NodeId, NodeKind};
use super::super::*;
use super::FormatOption;
const C: &str = "document.export";
const ENCODING: FormatOption = FormatOption {
name: "encoding",
ty: "string",
default: "\"utf8\"",
description: "utf8 (no byte order mark) or utf16 (little-endian, with a byte order mark)",
};
const LINE_ENDINGS: FormatOption =
FormatOption { name: "lineEndings", ty: "string", default: "\"lf\"", description: "lf (macOS, Linux) or crlf (Windows)" };
const SELECTION_ONLY: FormatOption =
FormatOption { name: "selectionOnly", ty: "boolean", default: "false", description: "only the selected type objects (document.export)" };
/// The options `document.formats` lists for text.
pub(super) const OPTIONS: &[FormatOption] = &[ENCODING, LINE_ENDINGS, SELECTION_ONLY];
#[derive(Default, Deserialize)]
#[serde(default, rename_all = "camelCase")]
struct TextOptions {
encoding: Option<String>,
line_endings: Option<String>,
}
/// The text file of `doc` with the text options of `p`.
pub(super) fn encode(doc: &Document, p: &Value) -> Result<Vec<u8>> {
let o = if p.is_object() { TextOptions::deserialize(p).map_err(|e| bad(C, format!("Text options: {e}")))? } else { TextOptions::default() };
let eol = match o.line_endings.as_deref().map(str::to_ascii_lowercase).as_deref() {
None | Some("lf") => "\n",
Some("crlf") => "\r\n",
Some(other) => return Err(bad(C, format!("lineEndings `{other}`: lf or crlf"))),
};
let mut text = String::new();
for story in stories(doc) {
// Any line break in the story becomes the chosen one; every story ends with one.
let story = story.replace("\r\n", "\n").replace('\r', "\n");
for line in story.split('\n') {
text.push_str(line);
text.push_str(eol);
}
}
match o.encoding.as_deref().map(|e| e.to_ascii_lowercase().replace(['-', '_'], "")).as_deref() {
None | Some("utf8") => Ok(text.into_bytes()),
Some("utf16" | "utf16le" | "unicode") => Ok([0xFF, 0xFE].into_iter().chain(text.encode_utf16().flat_map(u16::to_le_bytes)).collect()),
Some(_) => Err(bad(C, format!("encoding `{}`: utf8 or utf16", o.encoding.unwrap_or_default()))),
}
}
/// The text of every visible story in stacking order (back to front).
fn stories(doc: &Document) -> Vec<String> {
let mut out = vec![];
let mut done = HashSet::new();
for l in &doc.layers {
collect(doc, l, &mut done, &mut out);
}
out
}
fn collect(doc: &Document, n: &Node, done: &mut HashSet<NodeId>, out: &mut Vec<String>) {
if !n.visible || matches!(n.kind, NodeKind::Layer { template: true, .. }) {
return;
}
if let NodeKind::Text(t) = &n.kind {
if done.contains(&n.id) {
return;
}
// A threaded frame: the whole thread's story, once.
match doc.text_threads.iter().find(|th| th.contains(&n.id)) {
Some(thread) => {
done.extend(thread.iter().copied());
let frames = thread.iter().filter_map(|id| doc.node(*id)).filter_map(|f| match &f.kind {
NodeKind::Text(t) => Some(t.plain_text()),
_ => None,
});
out.push(frames.collect());
}
None => out.push(t.plain_text()),
}
return;
}
for c in n.children().into_iter().flatten() {
collect(doc, c, done, out);
}
}