Files
wordcraft/crates/formats/src/comments.rs
T
Michael MuandClaude Opus 5.5 fede86cd1a Tests: comment export discovery counts work steps instead of timing (#5618)
`comment_export_discovery_scales_with_unrelated_markers` timed `notes` on 2,000 and 16,000
markers through /proc/thread-self/schedstat. That count moves only at a scheduler tick or switch,
so a 17 µs baseline read as nothing or a whole tick, and gate failed on linear code (58.68x on
PR #3228's run 41932894607). As #5608 did for the balloon anchors, the test now counts the
characters the walk visits (`wordcraft_doc::work`): 4,010 -> 32,010 steps, 8.0x, every run. A
prefix scan per marker injected into `notes` counts 63.9x and fails it.

`work::steps` becomes public so a crate that walks a paragraph's text itself can count it; outside
tests it is still nothing.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 09:14:38 +01:00

206 lines
6.9 KiB
Rust

//! Print Markup for the text formats: HTML, Markdown and plain text copies end with the
//! document's comments, numbered in document order, each with its author, the text it comments
//! on, what it says and its replies (like the notes at the end of a printed document).
use std::collections::{BTreeMap, HashMap, HashSet};
use wordcraft_doc::para::{InlineObject, OBJ};
use wordcraft_doc::{Document, StoryRef};
/// Longest quoted anchor text (characters).
const MAX_QUOTE: usize = 200;
const MAX_COMMENTS: usize = 5_000;
const MAX_REPLIES: usize = 200;
const MAX_PARENT_CHAIN: usize = 64;
/// One comment as written out.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Note {
pub author: String,
/// The text it comments on (shortened), or empty.
pub quote: String,
pub text: String,
/// (author, text) of each reply, oldest first.
pub replies: Vec<(String, String)>,
}
fn root(doc: &Document, id: u32) -> u32 {
let mut cur = id;
for _ in 0..MAX_PARENT_CHAIN {
match doc.comments.get(&cur).and_then(|c| c.parent) {
Some(p) if p != cur && doc.comments.contains_key(&p) => cur = p,
_ => break,
}
}
cur
}
fn author(a: &str) -> String {
if a.trim().is_empty() { "Author".to_string() } else { a.trim().to_string() }
}
fn text_of(doc: &Document, part: u32) -> String {
doc.plain_text(StoryRef::Part(part)).split_whitespace().collect::<Vec<_>>().join(" ")
}
/// The document's top-level comments: those anchored in the body in document order, then the
/// rest; replies go under the comment they answer.
pub fn notes(doc: &Document) -> Vec<Note> {
if doc.comments.is_empty() {
return Vec::new();
}
// Anchor order and the text between each comment's start and end.
let mut order: Vec<u32> = Vec::new();
let mut seen = HashSet::new();
let mut quotes: HashMap<u32, String> = HashMap::new();
let mut open: Vec<u32> = Vec::new();
for path in doc.para_paths(StoryRef::Body) {
let Some(p) = doc.para(StoryRef::Body, &path) else { continue };
let mut objects = p.objects.iter();
for c in p.text.chars() {
wordcraft_doc::work::steps(1);
if c == OBJ {
match objects.next() {
Some(InlineObject::CommentStart { id }) => {
if seen.insert(*id) {
order.push(*id);
}
if !open.contains(id) && open.len() < 64 {
open.push(*id);
}
}
Some(InlineObject::CommentEnd { id }) => open.retain(|x| x != id),
_ => {}
}
continue;
}
for id in &open {
let q = quotes.entry(*id).or_default();
if q.chars().count() <= MAX_QUOTE {
q.push(c);
}
}
}
for id in &open {
quotes.entry(*id).or_default().push(' ');
}
}
for id in doc.comments.keys() {
if seen.insert(*id) {
order.push(*id);
}
}
let mut replies: BTreeMap<u32, Vec<u32>> = BTreeMap::new();
for id in doc.comments.keys() {
let r = root(doc, *id);
if r != *id {
let v = replies.entry(r).or_default();
if v.len() < MAX_REPLIES {
v.push(*id);
}
}
}
let mut out = Vec::new();
for id in order {
let Some(c) = doc.comments.get(&id) else { continue };
if root(doc, id) != id {
continue;
}
let quote = quotes.get(&id).map(|q| q.split_whitespace().collect::<Vec<_>>().join(" ")).unwrap_or_default();
let quote = if quote.chars().count() > MAX_QUOTE { format!("{}…", quote.chars().take(MAX_QUOTE).collect::<String>()) } else { quote };
let replies =
replies.get(&id).into_iter().flatten().filter_map(|r| doc.comments.get(r)).map(|r| (author(&r.author), text_of(doc, r.part))).collect();
out.push(Note { author: author(&c.author), quote, text: text_of(doc, c.part), replies });
if out.len() >= MAX_COMMENTS {
break;
}
}
out
}
/// `bytes` (a copy in format `ext`) with the comments written at its end.
pub fn append(ext: &str, doc: &Document, bytes: Vec<u8>) -> Vec<u8> {
let notes = notes(doc);
if notes.is_empty() {
return bytes;
}
match ext {
"html" | "htm" | "xhtml" => {
let mut s = match String::from_utf8(bytes) {
Ok(s) => s,
Err(e) => return e.into_bytes(),
};
let at = s.rfind("</body>").unwrap_or(s.len());
s.insert_str(at, &html(&notes));
s.into_bytes()
}
"md" | "markdown" => {
let mut s = bytes;
s.extend_from_slice(markdown(&notes).as_bytes());
s
}
"txt" | "text" => {
let mut s = bytes;
s.extend_from_slice(text(&notes).as_bytes());
s
}
_ => bytes,
}
}
fn html(notes: &[Note]) -> String {
use crate::html::esc;
let mut s = String::from("<section class=\"comments\">\n<h2>Comments</h2>\n<ol>\n");
for (i, n) in notes.iter().enumerate() {
s.push_str(&format!("<li id=\"comment-{}\"><p><strong>{}</strong>", i + 1, esc(&n.author)));
if !n.quote.is_empty() {
s.push_str(&format!(" on <q>{}</q>", esc(&n.quote)));
}
s.push_str(&format!(": {}</p>", esc(&n.text)));
if !n.replies.is_empty() {
s.push_str("<ul>");
for (a, t) in &n.replies {
s.push_str(&format!("<li><strong>{}</strong>: {}</li>", esc(a), esc(t)));
}
s.push_str("</ul>");
}
s.push_str("</li>\n");
}
s.push_str("</ol>\n</section>\n");
s
}
fn markdown(notes: &[Note]) -> String {
use crate::markdown::escape;
let mut s = String::from("\n\n## Comments\n\n");
for (i, n) in notes.iter().enumerate() {
s.push_str(&format!("{}. **{}**", i + 1, escape(&n.author)));
if !n.quote.is_empty() {
s.push_str(&format!(" on “{}”", escape(&n.quote)));
}
s.push_str(&format!(": {}\n", escape(&n.text)));
for (a, t) in &n.replies {
s.push_str(&format!(" - **{}**: {}\n", escape(a), escape(t)));
}
}
s
}
fn text(notes: &[Note]) -> String {
let mut s = String::from("\r\n\r\nComments\r\n");
for (i, n) in notes.iter().enumerate() {
s.push_str(&format!("\r\n[{}] {}", i + 1, n.author));
if !n.quote.is_empty() {
s.push_str(&format!(" on “{}”", n.quote));
}
s.push_str(&format!(": {}\r\n", n.text));
for (a, t) in &n.replies {
s.push_str(&format!(" {a}: {t}\r\n"));
}
}
s
}
#[cfg(test)]
mod tests;