mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-28 20:26:57 -06:00
# Conflicts: # ROADMAP.md # crates/engine/src/cmd/edit.rs # crates/engine/src/cmd/style.rs # crates/engine/src/cmd/text.rs # crates/engine/src/cmd/thread.rs # crates/geom/src/corners.rs # crates/idml/src/import.rs # crates/idml/src/tests.rs # crates/mcp/src/backend.rs # crates/tools/src/select.rs # crates/tools/src/tests.rs # crates/ui-egui/src/canvas.rs # crates/ui-egui/src/control.rs # crates/ui-egui/src/lib.rs # crates/ui-egui/src/menus.rs # crates/ui-egui/src/toolbar.rs # docs/gaps.md # docs/target-app-parity.md
397 lines
19 KiB
Rust
397 lines
19 KiB
Rust
//! Threading text frames: thread a frame after another (their stories merge), break a thread,
|
|
//! take a frame out of its thread. The Selection tool's loaded text cursor runs these.
|
|
|
|
use designcraft_doc::{Document, ItemId, ParaFormat, Selection, StoryId};
|
|
use designcraft_geom::Rect;
|
|
use serde_json::{Value, json};
|
|
|
|
use super::{CommandSpec, bad, bool_or, cmd, has_doc, id_param, rect_param, spread_param, str_param};
|
|
use crate::{Result, Session};
|
|
|
|
/// Pages Shift-click autoflow may add before it gives up.
|
|
const AUTOFLOW_MAX_PAGES: usize = 2000;
|
|
|
|
pub fn specs() -> Vec<CommandSpec> {
|
|
vec![
|
|
cmd!(
|
|
"text.thread",
|
|
"Thread Text Frames",
|
|
[],
|
|
None,
|
|
"{from?, to?, rect?: [x0,y0,x1,y1] (spread coords), spread?, autoflow?: bool} — threads frame `to` right after frame `from` (between `from` and the frame that followed it, if any). `to` is an empty frame or a text frame nothing threads into yet; its text joins the end of the story. Give `rect` in place of `from` or `to` to thread a new text frame made there on `spread`: `{from, rect}` adds it after `from`, `{rect, to}` in front of `to`. autoflow: then add pages with threaded frames until the story fits. Selects the frame that joined the thread. → {story, from, to, overset, pagesAdded}",
|
|
has_doc,
|
|
thread
|
|
),
|
|
cmd!(
|
|
"text.unthread",
|
|
"Break Thread",
|
|
[],
|
|
None,
|
|
"{frame?, side?: after|before} — breaks the thread after (or before) `frame` (default: the selected text frame). The frames from the break on stay threaded to each other but become empty; the text stays in the story, overset if the earlier frames can't hold it",
|
|
has_doc,
|
|
unthread
|
|
),
|
|
cmd!(
|
|
"text.removeFromThread",
|
|
"Remove Frame from Thread",
|
|
[],
|
|
None,
|
|
"{frame?} — takes `frame` (default: the selected text frame) out of its thread: the frames before and after it join up and keep the text, `frame` stays empty",
|
|
has_doc,
|
|
remove_from_thread
|
|
),
|
|
]
|
|
}
|
|
|
|
/// `frame` param, or the first selected text frame.
|
|
fn frame_param(s: &Session, p: &Value, cmd: &str) -> Result<ItemId> {
|
|
let st = s.doc()?;
|
|
let id = id_param(p, "frame")
|
|
.or_else(|| st.selection.items.iter().copied().find(|i| st.doc.item(*i).is_some_and(|it| it.is_text_frame())))
|
|
.ok_or_else(|| bad(cmd, "no text frame given or selected"))?;
|
|
if !st.doc.item(id).is_some_and(|it| it.is_text_frame()) {
|
|
return Err(bad(cmd, format!("{id} is not a text frame")));
|
|
}
|
|
check_editable(&st.doc, id, cmd)?;
|
|
Ok(id)
|
|
}
|
|
|
|
fn check_editable(d: &Document, id: ItemId, cmd: &str) -> Result<()> {
|
|
let it = d.item(id).ok_or(designcraft_doc::DocError::NoItem(id))?;
|
|
if it.locked || d.layer(it.layer).is_some_and(|l| l.locked) {
|
|
return Err(bad(cmd, format!("{id} is locked or on a locked layer")));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// A rect param made safe for a frame: finite, ordered, at least half a point each way.
|
|
fn frame_rect(p: &Value) -> Result<Option<Rect>> {
|
|
let Some(r) = rect_param(p, "rect") else { return Ok(None) };
|
|
if ![r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite()) {
|
|
return Err(bad("text.thread", "rect must be finite"));
|
|
}
|
|
let (x0, x1) = (r.x0.min(r.x1), r.x0.max(r.x1));
|
|
let (y0, y1) = (r.y0.min(r.y1), r.y0.max(r.y1));
|
|
Ok(Some(Rect::new(x0, y0, x1.max(x0 + 0.5), y1.max(y0 + 0.5))))
|
|
}
|
|
|
|
fn thread(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let (from, to, rect) = (id_param(p, "from"), id_param(p, "to"), frame_rect(p)?);
|
|
let sr = spread_param(p, "text.thread")?;
|
|
let autoflow = bool_or(p, "autoflow", false);
|
|
let layer = s.doc()?.active_layer;
|
|
s.edit(|d, sel| {
|
|
let mut new_frame = || -> Result<ItemId> {
|
|
let r = rect.ok_or_else(|| bad("text.thread", "give two of from, to and rect"))?;
|
|
if d.layer(layer).is_none_or(|l| l.locked || !l.visible) {
|
|
return Err(bad("text.thread", "the active layer is locked or hidden"));
|
|
}
|
|
let para = ParaFormat { style: d.styles.default_paragraph.clone(), ..Default::default() };
|
|
Ok(d.add_text_frame(sr, r, layer, "", para)?.0)
|
|
};
|
|
let (from, to, joined) = match (from, to) {
|
|
(Some(f), Some(t)) if rect.is_none() => (f, t, t),
|
|
(Some(f), None) => {
|
|
let t = new_frame()?;
|
|
(f, t, t)
|
|
}
|
|
(None, Some(t)) => {
|
|
let f = new_frame()?;
|
|
(f, t, f)
|
|
}
|
|
_ => return Err(bad("text.thread", "give two of from, to and rect")),
|
|
};
|
|
d.check_thread(from, to)?;
|
|
d.thread_keeping_story(from, to)?;
|
|
let sid = d.item(from).and_then(|i| i.text_frame()).map(|t| t.story).ok_or_else(|| bad("text.thread", "threading lost the story"))?;
|
|
let pages = if autoflow { super::place_text_autoflow(d, sid, AUTOFLOW_MAX_PAGES, "text.thread", false)? } else { 0 };
|
|
*sel = Selection::items(vec![joined]);
|
|
Ok(json!({"story": sid.0, "from": from.0, "to": to.0, "overset": overset(d, sid), "pagesAdded": pages}))
|
|
})
|
|
}
|
|
|
|
fn overset(d: &Document, sid: StoryId) -> bool {
|
|
designcraft_compose::compose_story(d, sid, &Default::default()).overset_at.is_some()
|
|
}
|
|
|
|
fn unthread(s: &mut Session, p: &Value) -> Result<Value> {
|
|
const CMD: &str = "text.unthread";
|
|
let frame = frame_param(s, p, CMD)?;
|
|
let d0 = &s.doc()?.doc;
|
|
let at = match str_param(p, "side").unwrap_or("after") {
|
|
"after" => d0.next_frame(frame).map(|_| frame).ok_or_else(|| bad(CMD, format!("no frame follows {frame} in its thread")))?,
|
|
"before" => d0.prev_frame(frame).ok_or_else(|| bad(CMD, format!("no frame comes before {frame} in its thread")))?,
|
|
other => return Err(bad(CMD, format!("unknown side `{other}` (after, before)"))),
|
|
};
|
|
check_editable(d0, at, CMD)?;
|
|
if let Some(next) = d0.next_frame(at) {
|
|
check_editable(d0, next, CMD)?;
|
|
}
|
|
s.edit(|d, _| {
|
|
d.unthread_after(at)?;
|
|
let sid = d.item(at).and_then(|i| i.text_frame()).map(|t| t.story).ok_or_else(|| bad(CMD, "no story"))?;
|
|
Ok(json!({"story": sid.0, "overset": overset(d, sid)}))
|
|
})
|
|
}
|
|
|
|
fn remove_from_thread(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let frame = frame_param(s, p, "text.removeFromThread")?;
|
|
let d = &s.doc()?.doc;
|
|
for neighbour in [d.prev_frame(frame), d.next_frame(frame)].into_iter().flatten() {
|
|
check_editable(d, neighbour, "text.removeFromThread")?;
|
|
}
|
|
s.edit(|d, _| {
|
|
d.remove_from_thread(frame)?;
|
|
Ok(json!({}))
|
|
})
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use serde_json::json;
|
|
|
|
use crate::Session;
|
|
|
|
fn session() -> Session {
|
|
let mut s = Session::new();
|
|
s.execute("file.new", &json!({"pages": 2})).unwrap();
|
|
s
|
|
}
|
|
|
|
fn text_frame(s: &mut Session, rect: [f64; 4], text: &str) -> (u64, u64) {
|
|
let r = s.execute("frame.create", &json!({"rect": rect, "content": "text", "text": text, "caret": false})).unwrap();
|
|
(r["id"].as_u64().unwrap(), r["story"].as_u64().unwrap())
|
|
}
|
|
|
|
fn story(s: &mut Session, frame: u64) -> serde_json::Value {
|
|
s.execute("story.get", &json!({"frame": frame})).unwrap()
|
|
}
|
|
|
|
fn frames(s: &mut Session, frame: u64) -> Vec<u64> {
|
|
story(s, frame)["frames"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect()
|
|
}
|
|
|
|
const LONG: &str = "Threaded text keeps flowing from one frame into the next one. ";
|
|
|
|
#[test]
|
|
fn thread_into_an_empty_frame_and_undo() {
|
|
let mut s = session();
|
|
let (a, sid) = text_frame(&mut s, [72.0, 72.0, 200.0, 120.0], &LONG.repeat(10));
|
|
let b = s.execute("frame.create", &json!({"rect": [72, 300, 200, 400], "content": "unassigned"})).unwrap()["id"].as_u64().unwrap();
|
|
assert!(!story(&mut s, a)["overset"].is_null(), "the first frame is overset");
|
|
let r = s.execute("text.thread", &json!({"from": a, "to": b})).unwrap();
|
|
assert_eq!(r["story"].as_u64(), Some(sid));
|
|
assert_eq!(frames(&mut s, a), [a, b]);
|
|
let d = &s.doc().unwrap().doc;
|
|
assert!(d.item(designcraft_doc::ItemId(b)).unwrap().is_text_frame());
|
|
assert_eq!(s.doc().unwrap().selection.items, [designcraft_doc::ItemId(b)]);
|
|
// The text now reaches the second frame.
|
|
let cs = designcraft_compose::compose_story(&s.doc().unwrap().doc, designcraft_doc::StoryId(sid), &Default::default());
|
|
assert!(cs.frames.iter().any(|f| f.frame.0 == b && !f.range.is_empty()), "text flows into the new frame");
|
|
s.doc().unwrap().doc.check().unwrap();
|
|
s.execute("edit.undo", &json!({})).unwrap();
|
|
assert_eq!(frames(&mut s, a), [a]);
|
|
assert!(!s.doc().unwrap().doc.item(designcraft_doc::ItemId(b)).unwrap().is_text_frame());
|
|
}
|
|
|
|
#[test]
|
|
fn thread_into_a_text_frame_merges_its_story() {
|
|
let mut s = session();
|
|
let (a, sa) = text_frame(&mut s, [72.0, 72.0, 200.0, 200.0], "First");
|
|
let (b, sb) = text_frame(&mut s, [72.0, 300.0, 200.0, 400.0], "Second");
|
|
s.execute("text.thread", &json!({"from": a, "to": b})).unwrap();
|
|
let st = story(&mut s, b);
|
|
assert_eq!(st["id"].as_u64(), Some(sa));
|
|
assert_eq!(st["text"], "First\nSecond");
|
|
assert!(s.doc().unwrap().doc.story(designcraft_doc::StoryId(sb)).is_none());
|
|
s.doc().unwrap().doc.check().unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn thread_into_the_middle_of_a_thread() {
|
|
let mut s = session();
|
|
let (a, _) = text_frame(&mut s, [72.0, 72.0, 200.0, 120.0], &LONG.repeat(8));
|
|
let (c, _) = text_frame(&mut s, [72.0, 500.0, 200.0, 600.0], "");
|
|
s.execute("text.thread", &json!({"from": a, "to": c})).unwrap();
|
|
let (b, _) = text_frame(&mut s, [72.0, 300.0, 200.0, 400.0], "");
|
|
s.execute("text.thread", &json!({"from": a, "to": b})).unwrap();
|
|
assert_eq!(frames(&mut s, a), [a, b, c]);
|
|
s.doc().unwrap().doc.check().unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn bad_threads_are_errors_and_change_nothing() {
|
|
let mut s = session();
|
|
let (a, _) = text_frame(&mut s, [72.0, 72.0, 200.0, 120.0], "a");
|
|
let (b, _) = text_frame(&mut s, [72.0, 300.0, 200.0, 400.0], "b");
|
|
let (x, _) = text_frame(&mut s, [300.0, 72.0, 400.0, 120.0], "x");
|
|
let (y, _) = text_frame(&mut s, [300.0, 300.0, 400.0, 400.0], "y");
|
|
let g = s.execute("line.create", &json!({"a": [300, 500], "b": [400, 600]})).unwrap()["id"].as_u64().unwrap();
|
|
let group = {
|
|
let p = s.execute("frame.create", &json!({"rect": [500, 500, 550, 550], "content": "unassigned"})).unwrap()["id"].clone();
|
|
let q = s.execute("frame.create", &json!({"rect": [560, 500, 600, 550], "content": "unassigned"})).unwrap()["id"].clone();
|
|
s.execute("selection.set", &json!({"ids": [p, q]})).unwrap();
|
|
s.execute("object.group", &json!({})).unwrap();
|
|
s.doc().unwrap().selection.items[0].0
|
|
};
|
|
s.execute("text.thread", &json!({"from": a, "to": b})).unwrap();
|
|
s.execute("text.thread", &json!({"from": x, "to": y})).unwrap();
|
|
let undo = s.doc().unwrap().history.undo.len();
|
|
let doc = s.doc().unwrap().doc.clone();
|
|
for p in [
|
|
json!({"from": a, "to": a}),
|
|
json!({"from": a, "to": g}),
|
|
json!({"from": g, "to": a}),
|
|
json!({"from": a, "to": group}),
|
|
// Earlier in the same thread.
|
|
json!({"from": b, "to": a}),
|
|
// Text already flows into y from x.
|
|
json!({"from": a, "to": y}),
|
|
json!({"from": a, "to": 999_999}),
|
|
json!({"from": a}),
|
|
json!({"from": a, "to": b, "rect": [0, 0, 10, 10]}),
|
|
json!({"rect": [0, 0, 10, 10]}),
|
|
json!({"from": a, "rect": [0, 0, 10, 10], "spread": 99}),
|
|
json!({"from": "a", "to": [1]}),
|
|
json!([1, 2]),
|
|
] {
|
|
assert!(s.execute("text.thread", &p).is_err(), "{p}");
|
|
}
|
|
assert_eq!(s.doc().unwrap().history.undo.len(), undo);
|
|
assert_eq!(*s.doc().unwrap().doc, *doc);
|
|
}
|
|
|
|
#[test]
|
|
fn locked_frames_and_layers_refuse_threads() {
|
|
let mut s = session();
|
|
let (a, _) = text_frame(&mut s, [72.0, 72.0, 200.0, 120.0], &LONG.repeat(8));
|
|
let (b, _) = text_frame(&mut s, [72.0, 300.0, 200.0, 400.0], "");
|
|
let layer = s.doc().unwrap().active_layer.0;
|
|
let undo = s.doc().unwrap().history.undo.len();
|
|
s.execute("object.lock", &json!({"ids": [b]})).unwrap();
|
|
assert!(s.execute("text.thread", &json!({"from": a, "to": b})).is_err(), "locked frame");
|
|
assert!(s.execute("text.thread", &json!({"from": b, "to": a})).is_err(), "locked frame");
|
|
s.execute("edit.undo", &json!({})).unwrap();
|
|
for (set, rect) in [(json!({"locked": true}), false), (json!({"locked": true}), true), (json!({"visible": false}), true)] {
|
|
let mut p = json!({"id": layer});
|
|
p.as_object_mut().unwrap().extend(set.as_object().unwrap().clone());
|
|
s.execute("layer.set", &p).unwrap();
|
|
let t = if rect { json!({"from": a, "rect": [300, 300, 400, 400]}) } else { json!({"from": a, "to": b}) };
|
|
assert!(s.execute("text.thread", &t).is_err(), "{p} {t}");
|
|
s.execute("edit.undo", &json!({})).unwrap();
|
|
}
|
|
assert_eq!(s.doc().unwrap().history.undo.len(), undo);
|
|
assert_eq!(frames(&mut s, a), [a]);
|
|
}
|
|
|
|
#[test]
|
|
fn thread_a_new_frame_after_or_in_front() {
|
|
let mut s = session();
|
|
let (a, sa) = text_frame(&mut s, [72.0, 72.0, 200.0, 120.0], &LONG.repeat(8));
|
|
let r = s.execute("text.thread", &json!({"from": a, "rect": [72, 300, 200, 400]})).unwrap();
|
|
let n = r["to"].as_u64().unwrap();
|
|
assert_eq!(frames(&mut s, a), [a, n]);
|
|
assert_eq!(s.doc().unwrap().selection.items, [designcraft_doc::ItemId(n)]);
|
|
let b = s.doc().unwrap().doc.item(designcraft_doc::ItemId(n)).unwrap().bounds();
|
|
assert_eq!(b, Rect::new(72.0, 300.0, 200.0, 400.0));
|
|
// In front of the first frame: the story keeps its id and text.
|
|
let r = s.execute("text.thread", &json!({"rect": [300, 72, 400, 120], "to": a})).unwrap();
|
|
let m = r["from"].as_u64().unwrap();
|
|
assert_eq!(r["story"].as_u64(), Some(sa));
|
|
assert_eq!(frames(&mut s, a), [m, a, n]);
|
|
assert_eq!(story(&mut s, a)["text"], LONG.repeat(8));
|
|
s.doc().unwrap().doc.check().unwrap();
|
|
// One undo step each.
|
|
s.execute("edit.undo", &json!({})).unwrap();
|
|
assert_eq!(frames(&mut s, a), [a, n]);
|
|
}
|
|
|
|
use designcraft_geom::Rect;
|
|
|
|
#[test]
|
|
fn thread_with_autoflow_adds_pages_until_the_text_fits() {
|
|
let mut s = session();
|
|
let (a, _) = text_frame(&mut s, [72.0, 72.0, 300.0, 200.0], &LONG.repeat(300));
|
|
let r = s.execute("text.thread", &json!({"from": a, "rect": [72, 300, 300, 700], "autoflow": true})).unwrap();
|
|
assert_eq!(r["overset"], false);
|
|
assert!(r["pagesAdded"].as_u64().unwrap() >= 1, "{r}");
|
|
assert!(frames(&mut s, a).len() >= 3);
|
|
s.doc().unwrap().doc.check().unwrap();
|
|
// One undo removes the frames and the pages.
|
|
s.execute("edit.undo", &json!({})).unwrap();
|
|
assert_eq!(frames(&mut s, a), [a]);
|
|
assert_eq!(s.doc().unwrap().doc.page_count(), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn autoflow_from_a_parent_frame_is_an_error() {
|
|
let mut s = session();
|
|
let parent = json!({"kind": "parent", "index": 0});
|
|
let r = s.execute(
|
|
"frame.create",
|
|
&json!({"rect": [72, 72, 300, 200], "content": "text", "text": LONG.repeat(300), "caret": false, "spread": parent}),
|
|
);
|
|
let a = r.unwrap()["id"].as_u64().unwrap();
|
|
let undo = s.doc().unwrap().history.undo.len();
|
|
let doc = s.doc().unwrap().doc.clone();
|
|
let e = s.execute("text.thread", &json!({"from": a, "rect": [72, 300, 300, 700], "spread": parent, "autoflow": true})).unwrap_err();
|
|
assert!(e.to_string().contains("text.thread"), "{e}");
|
|
assert_eq!(s.doc().unwrap().history.undo.len(), undo);
|
|
assert_eq!(*s.doc().unwrap().doc, *doc);
|
|
assert_eq!(s.doc().unwrap().doc.page_count(), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn unthread_after_and_before() {
|
|
let mut s = session();
|
|
let text = LONG.repeat(12);
|
|
let (a, sa) = text_frame(&mut s, [72.0, 72.0, 200.0, 120.0], &text);
|
|
let (b, _) = text_frame(&mut s, [72.0, 200.0, 200.0, 250.0], "");
|
|
let (c, _) = text_frame(&mut s, [72.0, 300.0, 200.0, 350.0], "");
|
|
s.execute("text.thread", &json!({"from": a, "to": b})).unwrap();
|
|
s.execute("text.thread", &json!({"from": b, "to": c})).unwrap();
|
|
// Break after the first frame: b and c stay threaded to each other, empty.
|
|
let r = s.execute("text.unthread", &json!({"frame": a})).unwrap();
|
|
assert_eq!(r["story"].as_u64(), Some(sa));
|
|
assert_eq!(r["overset"], true);
|
|
assert_eq!(frames(&mut s, a), [a]);
|
|
assert_eq!(story(&mut s, a)["text"], text);
|
|
assert_eq!(frames(&mut s, b), [b, c]);
|
|
assert_eq!(story(&mut s, b)["text"], "");
|
|
s.doc().unwrap().doc.check().unwrap();
|
|
s.execute("edit.undo", &json!({})).unwrap();
|
|
assert_eq!(frames(&mut s, a), [a, b, c]);
|
|
// Break before the last frame, with the frame selected.
|
|
s.execute("selection.set", &json!({"ids": [c]})).unwrap();
|
|
s.execute("text.unthread", &json!({"side": "before"})).unwrap();
|
|
assert_eq!(frames(&mut s, a), [a, b]);
|
|
assert_eq!(frames(&mut s, c), [c]);
|
|
// Nothing to break.
|
|
assert!(s.execute("text.unthread", &json!({"frame": b})).is_err());
|
|
assert!(s.execute("text.unthread", &json!({"frame": a, "side": "before"})).is_err());
|
|
assert!(s.execute("text.unthread", &json!({"frame": a, "side": "sideways"})).is_err());
|
|
assert!(s.execute("text.unthread", &json!({"frame": 999_999})).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn deleting_or_removing_a_threaded_frame_keeps_the_text_flowing() {
|
|
let mut s = session();
|
|
let text = LONG.repeat(12);
|
|
let (a, _) = text_frame(&mut s, [72.0, 72.0, 200.0, 120.0], &text);
|
|
let (b, _) = text_frame(&mut s, [72.0, 200.0, 200.0, 250.0], "");
|
|
let (c, _) = text_frame(&mut s, [72.0, 300.0, 200.0, 350.0], "");
|
|
s.execute("text.thread", &json!({"from": a, "to": b})).unwrap();
|
|
s.execute("text.thread", &json!({"from": b, "to": c})).unwrap();
|
|
s.execute("text.removeFromThread", &json!({"frame": b})).unwrap();
|
|
assert_eq!(frames(&mut s, a), [a, c]);
|
|
assert_eq!(story(&mut s, a)["text"], text);
|
|
assert_eq!(story(&mut s, b)["text"], "");
|
|
assert!(s.execute("text.removeFromThread", &json!({"frame": b})).is_err());
|
|
s.execute("edit.undo", &json!({})).unwrap();
|
|
s.execute("edit.clear", &json!({"ids": [b]})).unwrap();
|
|
assert_eq!(frames(&mut s, a), [a, c]);
|
|
assert_eq!(story(&mut s, a)["text"], text);
|
|
s.doc().unwrap().doc.check().unwrap();
|
|
}
|
|
}
|