1use libxml::tree::Node;
32
33use crate::document::PostDocument;
34
35#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
46pub enum Whatsout {
47 #[default]
48 Document,
49 Fragment,
50 Math,
51 Archive,
52}
53
54impl Whatsout {
55 pub fn from_cli(s: &str) -> Option<Self> {
62 match s {
63 "document" => Some(Whatsout::Document),
64 "fragment" => Some(Whatsout::Fragment),
65 "math" => Some(Whatsout::Math),
66 _ if s.starts_with("archive") => Some(Whatsout::Archive),
67 _ => None,
68 }
69 }
70
71 pub fn as_cli(self) -> &'static str {
75 match self {
76 Whatsout::Document => "document",
77 Whatsout::Fragment => "fragment",
78 Whatsout::Math => "math",
79 Whatsout::Archive => "archive",
80 }
81 }
82
83 pub fn is_archive(self) -> bool { matches!(self, Whatsout::Archive) }
87
88 pub fn requires_post(self) -> bool { !matches!(self, Whatsout::Document) }
93}
94
95pub fn serialize_whatsout(doc: &PostDocument, mode: Whatsout) -> String {
103 match mode {
104 Whatsout::Document | Whatsout::Archive => doc.to_xml_string(),
107 Whatsout::Fragment => get_embeddable(doc)
108 .map(|n| doc.get_document().node_to_string(&n))
109 .unwrap_or_else(|| doc.to_xml_string()),
110 Whatsout::Math => get_math(doc)
111 .map(|n| doc.get_document().node_to_string(&n))
112 .unwrap_or_else(|| doc.to_xml_string()),
113 }
114}
115
116const MATH_XPATH: &str = "//*[local-name()='math' or local-name()='Math']";
120
121const MATH_IMG_XPATH: &str = "//*[local-name()='img' and contains(@class,'ltx_Math')]";
123
124const EMBEDDABLE_XPATH: &str = "//*[contains(@class,'ltx_document')]";
128
129const RDFA_ATTRS: &[&str] = &[
132 "prefix", "property", "content", "resource", "about", "typeof", "rel", "rev", "datatype",
133];
134
135fn is_unwrappable_div_class(class: &str) -> bool {
138 matches!(
139 class,
140 "ltx_page_main" | "ltx_page_content" | "ltx_document" | "ltx_para" | "ltx_header"
141 )
142}
143
144fn is_inline_child(name: &str) -> bool {
147 name.contains("math") || name.contains("text") || name.contains("span")
150}
151
152pub fn get_math(doc: &PostDocument) -> Option<Node> {
162 let math_nodes = doc.findnodes(MATH_XPATH);
163 let math_count = math_nodes.len();
164
165 if math_count == 0 {
166 let img_nodes = doc.findnodes(MATH_IMG_XPATH);
167 if img_nodes.is_empty() {
168 return get_embeddable(doc);
169 }
170 return img_nodes.into_iter().next();
171 }
172
173 let mut math = math_nodes.into_iter().next()?;
174 if math_count > 1 {
175 let descendant_math_xpath = format!(".{MATH_XPATH}");
182 let mut found = 0;
183 while found != math_count {
184 found = doc.findnodes_at(&descendant_math_xpath, Some(&math)).len();
185 if math.get_name().eq_ignore_ascii_case("math") {
186 found += 1;
187 }
188 if found != math_count {
189 match math.get_parent() {
190 Some(p) => math = p,
191 None => break,
192 }
193 }
194 }
195 while is_table_row_or_cell(&math.get_name()) {
198 match math.get_parent() {
199 Some(p) => math = p,
200 None => break,
201 }
202 }
203 }
204
205 Some(math)
206}
207
208pub fn get_embeddable(doc: &PostDocument) -> Option<Node> {
225 let root = doc.get_document_element()?;
226 let mut embeddable = doc
227 .findnodes(EMBEDDABLE_XPATH)
228 .into_iter()
229 .next()
230 .unwrap_or_else(|| root.clone());
231
232 loop {
234 if embeddable.get_name() != "div" {
235 break;
236 }
237 let children = embeddable.get_child_nodes();
238 if children.len() != 1 {
239 break;
240 }
241 let class = embeddable.get_attribute("class").unwrap_or_default();
242 if !is_unwrappable_div_class(&class) {
243 break;
244 }
245 if embeddable.get_attribute("style").is_some() {
246 break;
247 }
248 match embeddable.get_first_child() {
249 Some(c) => embeddable = c,
250 None => break,
251 }
252 }
253
254 if embeddable.get_name() == "p" {
256 let children = embeddable.get_child_nodes();
257 if !children.is_empty() && children.iter().all(|c| is_inline_child(&c.get_name())) {
258 let _ = embeddable.set_name("span");
259 let _ = embeddable.set_attribute("class", "text");
260 }
261 }
262
263 for attr in RDFA_ATTRS {
265 if let Some(value) = root.get_attribute(attr) {
266 let _ = embeddable.set_attribute(attr, &value);
267 }
268 }
269
270 Some(embeddable)
271}
272
273fn is_table_row_or_cell(name: &str) -> bool { matches!(name, "tr" | "td" | "TR" | "TD") }
274
275#[cfg(test)]
276mod tests {
277 use super::*;
278 use crate::document::PostDocumentOptions;
279
280 fn doc(xml: &str) -> PostDocument {
281 PostDocument::new_from_string(xml, PostDocumentOptions::default()).expect("parse test fixture")
282 }
283
284 #[test]
285 fn get_embeddable_returns_root_when_no_ltx_document() {
286 let d = doc("<html><body><p>hello</p></body></html>");
287 let node = get_embeddable(&d).expect("some node");
288 assert_eq!(node.get_name(), "html");
289 }
290
291 #[test]
298 fn get_embeddable_unwraps_single_child_wrappers_to_inline_span() {
299 let xml = r#"<html><body><div class="ltx_document"><div class="ltx_page_main"><p>Hello world</p></div></div></body></html>"#;
304 let d = doc(xml);
305 let node = get_embeddable(&d).expect("some node");
306 assert_eq!(node.get_name(), "span");
307 assert_eq!(node.get_attribute("class").as_deref(), Some("text"));
308 }
309
310 #[test]
311 fn get_embeddable_stops_at_multi_child() {
312 let xml =
314 r#"<html><body><div class="ltx_document"><p>first</p><p>second</p></div></body></html>"#;
315 let d = doc(xml);
316 let node = get_embeddable(&d).expect("some node");
317 assert_eq!(node.get_name(), "div");
318 assert_eq!(node.get_attribute("class").as_deref(), Some("ltx_document"));
319 }
320
321 #[test]
322 fn get_embeddable_keeps_p_when_child_is_non_inline_block() {
323 let xml = r#"<html><body><div class="ltx_document"><div class="ltx_para"><p><table>x</table></p></div></div></body></html>"#;
326 let d = doc(xml);
327 let node = get_embeddable(&d).expect("some node");
328 assert_eq!(node.get_name(), "p");
329 }
330
331 #[test]
332 fn get_math_returns_lone_math_node() {
333 let xml = r#"<html><body><p>some text</p><math xmlns="http://www.w3.org/1998/Math/MathML"><mi>x</mi></math></body></html>"#;
334 let d = doc(xml);
335 let node = get_math(&d).expect("some node");
336 assert_eq!(node.get_name(), "math");
337 }
338
339 #[test]
340 fn get_math_returns_lca_for_multiple_math() {
341 let xml = r#"<html><body><div id="container"><math xmlns="http://www.w3.org/1998/Math/MathML"><mi>a</mi></math><math xmlns="http://www.w3.org/1998/Math/MathML"><mi>b</mi></math></div></body></html>"#;
342 let d = doc(xml);
343 let node = get_math(&d).expect("some node");
344 assert_eq!(node.get_name(), "div");
347 assert_eq!(node.get_attribute("id").as_deref(), Some("container"));
348 }
349
350 #[test]
351 fn get_math_falls_through_to_img_when_no_math_elements() {
352 let xml = r#"<html><body><p>before</p><img class="ltx_Math" alt="x"/></body></html>"#;
353 let d = doc(xml);
354 let node = get_math(&d).expect("some node");
355 assert_eq!(node.get_name(), "img");
356 }
357
358 #[test]
359 fn get_math_falls_through_to_embeddable_when_no_math_at_all() {
360 let xml = r#"<html><body><div class="ltx_document"><p>just prose</p></div></body></html>"#;
364 let d = doc(xml);
365 let node = get_math(&d).expect("some node");
366 assert_eq!(node.get_name(), "span");
367 }
368
369 #[test]
370 fn whatsout_from_cli_recognized() {
371 assert_eq!(Whatsout::from_cli("document"), Some(Whatsout::Document));
372 assert_eq!(Whatsout::from_cli("fragment"), Some(Whatsout::Fragment));
373 assert_eq!(Whatsout::from_cli("math"), Some(Whatsout::Math));
374 assert_eq!(Whatsout::from_cli("archive"), Some(Whatsout::Archive));
379 assert_eq!(Whatsout::from_cli("archive::zip"), Some(Whatsout::Archive));
380 assert_eq!(Whatsout::from_cli("nonsense"), None);
381 }
382
383 #[test]
384 fn whatsout_default_is_document() {
385 assert_eq!(Whatsout::default(), Whatsout::Document);
386 }
387
388 #[test]
389 fn whatsout_is_archive_predicate() {
390 assert!(Whatsout::Archive.is_archive());
391 assert!(!Whatsout::Document.is_archive());
392 assert!(!Whatsout::Fragment.is_archive());
393 assert!(!Whatsout::Math.is_archive());
394 }
395
396 #[test]
397 fn whatsout_requires_post_for_non_document() {
398 assert!(!Whatsout::Document.requires_post());
400 assert!(Whatsout::Fragment.requires_post());
401 assert!(Whatsout::Math.requires_post());
402 assert!(Whatsout::Archive.requires_post());
403 }
404
405 #[test]
406 fn serialize_whatsout_archive_returns_full_document() {
407 let xml = r#"<html><body><div class="ltx_document"><p>hi</p></div></body></html>"#;
412 let d = doc(xml);
413 let archive = serialize_whatsout(&d, Whatsout::Archive);
414 let full = serialize_whatsout(&d, Whatsout::Document);
415 assert_eq!(archive, full);
416 assert!(archive.contains("<html>") && archive.contains("</html>"));
417 }
418
419 #[test]
420 fn serialize_whatsout_document_matches_full_xml() {
421 let xml = r#"<html><body><div class="ltx_document"><p>hi</p></div></body></html>"#;
422 let d = doc(xml);
423 let full = serialize_whatsout(&d, Whatsout::Document);
424 assert!(full.contains("<html>") && full.contains("</html>"));
425 }
426
427 #[test]
428 fn serialize_whatsout_fragment_strips_html_wrapper() {
429 let xml = r#"<html><body><div class="ltx_document"><p>hi</p></div></body></html>"#;
430 let d = doc(xml);
431 let frag = serialize_whatsout(&d, Whatsout::Fragment);
432 assert!(
436 !frag.contains("<html>"),
437 "frag contains html wrapper: {frag}"
438 );
439 assert!(frag.contains("hi"));
440 }
441
442 #[test]
443 fn serialize_whatsout_math_returns_math_subtree() {
444 let xml = r#"<html><body><p>txt</p><math xmlns="http://www.w3.org/1998/Math/MathML"><mi>z</mi></math></body></html>"#;
445 let d = doc(xml);
446 let m = serialize_whatsout(&d, Whatsout::Math);
447 assert!(m.contains("<mi>z</mi>"));
448 assert!(!m.contains("<html>"), "math contains html wrapper: {m}");
449 assert!(
450 !m.contains("<p>txt</p>"),
451 "math contains unrelated text: {m}"
452 );
453 }
454
455 #[test]
456 fn get_embeddable_copies_rdfa_from_root() {
457 let xml = r#"<html prefix="dc: http://purl.org/dc/terms/" typeof="ScholarlyArticle"><body><div class="ltx_document"><p>text</p></div></body></html>"#;
458 let d = doc(xml);
459 let node = get_embeddable(&d).expect("some node");
460 assert_eq!(
463 node.get_attribute("prefix").as_deref(),
464 Some("dc: http://purl.org/dc/terms/")
465 );
466 assert_eq!(
467 node.get_attribute("typeof").as_deref(),
468 Some("ScholarlyArticle")
469 );
470 }
471
472 #[test]
473 fn whatsout_cli_tag_round_trips() {
474 for w in [
475 Whatsout::Document,
476 Whatsout::Fragment,
477 Whatsout::Math,
478 Whatsout::Archive,
479 ] {
480 assert_eq!(
481 Whatsout::from_cli(w.as_cli()),
482 Some(w),
483 "as_cli must round-trip through from_cli for {w:?}"
484 );
485 }
486 }
487}