mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-09 12:32:52 +00:00
Word export named no language, so Word checked the spelling of every export as English and underlined a whole document in any other language. The PDF already says its language in the catalog's /Lang. When that is a well formed language tag, Word gets it as the default language of the text (w:docDefaults), in the attribute Word keeps for its script: w:eastAsia for Chinese, Japanese and Korean, w:bidi for Arabic, Hebrew, Persian and Urdu, w:val for the rest. Both HTML exports, with and without CSS, get <html lang>. Without /Lang, or with a malformed one, the files are exactly as before. docx_with_language, html_with_language and html_simple_with_language take the language; docx, html and html_simple stay as they are and call them with none. The engine reads /Lang through the existing initial view. The new export and engine tests fail without the change.
144 lines
6.6 KiB
Rust
144 lines
6.6 KiB
Rust
use super::as_docx::{hex, rgb8};
|
|
use super::layout::{Item, Page, items};
|
|
|
|
/// Whether a character may appear in the output. XML 1.0 forbids the C0 controls other than tab,
|
|
/// line feed and carriage return, and U+FFFE/U+FFFF. Text extracted from PDFs often holds them
|
|
/// (fonts without a usable `/ToUnicode`), and Word refuses a whole document over one (#72).
|
|
pub(crate) fn allowed(c: char) -> bool {
|
|
matches!(c, '\t' | '\n' | '\r') || !(c.is_ascii_control() || c == '\u{FFFE}' || c == '\u{FFFF}')
|
|
}
|
|
|
|
/// `lang` when it is a well formed language tag (BCP 47, as a PDF's `/Lang` holds one): a primary
|
|
/// language of two or three letters, then subtags of one to eight letters or digits, joined by
|
|
/// hyphens, 35 characters at most. Anything else names no language.
|
|
pub(crate) fn language_tag(lang: Option<&str>) -> Option<&str> {
|
|
let tag = lang?.trim();
|
|
let mut subtags = tag.split('-');
|
|
let primary = subtags.next()?;
|
|
let well_formed = tag.len() <= 35
|
|
&& (2..=3).contains(&primary.len())
|
|
&& primary.bytes().all(|b| b.is_ascii_alphabetic())
|
|
&& subtags.all(|s| (1..=8).contains(&s.len()) && s.bytes().all(|b| b.is_ascii_alphanumeric()));
|
|
well_formed.then_some(tag)
|
|
}
|
|
|
|
pub(crate) fn esc(s: &str) -> String {
|
|
let mut o = String::with_capacity(s.len());
|
|
for c in s.chars().filter(|c| allowed(*c)) {
|
|
match c {
|
|
'&' => o.push_str("&"),
|
|
'<' => o.push_str("<"),
|
|
'>' => o.push_str(">"),
|
|
'"' => o.push_str("""),
|
|
c => o.push(c),
|
|
}
|
|
}
|
|
o
|
|
}
|
|
|
|
pub(crate) fn base64(data: &[u8]) -> String {
|
|
const A: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
|
let mut s = String::with_capacity(data.len().div_ceil(3) * 4);
|
|
for c in data.chunks(3) {
|
|
let n = (c[0] as u32) << 16 | (*c.get(1).unwrap_or(&0) as u32) << 8 | *c.get(2).unwrap_or(&0) as u32;
|
|
s.push(A[(n >> 18) as usize & 63] as char);
|
|
s.push(A[(n >> 12) as usize & 63] as char);
|
|
s.push(if c.len() > 1 { A[(n >> 6) as usize & 63] as char } else { '=' });
|
|
s.push(if c.len() > 2 { A[n as usize & 63] as char } else { '=' });
|
|
}
|
|
s
|
|
}
|
|
|
|
/// One HTML file: headings and paragraphs, tables as real `<table>` elements, images inline, a
|
|
/// rule between pages.
|
|
pub fn html(pages: &[Page], title: &str) -> String {
|
|
html_with_language(pages, title, None)
|
|
}
|
|
|
|
/// A simple HTML export with no CSS; page layout and colours are omitted.
|
|
pub fn html_simple(pages: &[Page], title: &str) -> String {
|
|
html_simple_with_language(pages, title, None)
|
|
}
|
|
|
|
/// [`html`] for a document in `language` (a BCP 47 tag such as `bg-BG`, as a PDF's `/Lang` gives
|
|
/// it), which the page names (`<html lang>`) for spell checking, speech and hyphenation. A
|
|
/// language that is not a well formed tag is left out.
|
|
pub fn html_with_language(pages: &[Page], title: &str, language: Option<&str>) -> String {
|
|
html_with_mode(pages, title, false, language)
|
|
}
|
|
|
|
/// [`html_simple`] for a document in `language`, named as [`html_with_language`] names it: a
|
|
/// language is not styling, so the simple export keeps it.
|
|
pub fn html_simple_with_language(pages: &[Page], title: &str, language: Option<&str>) -> String {
|
|
html_with_mode(pages, title, true, language)
|
|
}
|
|
|
|
fn html_with_mode(pages: &[Page], title: &str, simple: bool, language: Option<&str>) -> String {
|
|
let lang = language_tag(language).map(|tag| format!(" lang=\"{}\"", esc(tag))).unwrap_or_default();
|
|
let mut s = format!(
|
|
"<!DOCTYPE html>\n<html{lang}>\n<head>\n<meta charset=\"utf-8\">\n<title>{}</title>\n{}\n</head>\n<body>\n",
|
|
esc(title),
|
|
if simple {
|
|
""
|
|
} else {
|
|
"<style>body{max-width:46em;margin:2em auto;font-family:sans-serif;line-height:1.45}img{max-width:100%}hr{border:0;border-top:1px solid #ccc;margin:2em 0}table{border-collapse:collapse;margin:1em 0}td,th{border:1px solid #999;padding:2px 6px;vertical-align:top}</style>"
|
|
}
|
|
);
|
|
for it in items(pages) {
|
|
match it {
|
|
Item::Para(runs, lvl) => {
|
|
let Some(first) = runs.first().map(|r| r.block) else { continue };
|
|
let mut t = String::new();
|
|
for r in &runs {
|
|
let mut run = esc(&r.text);
|
|
if r.block.italic {
|
|
run = format!("<em>{run}</em>");
|
|
}
|
|
if r.block.bold && lvl == 0 {
|
|
run = format!("<strong>{run}</strong>");
|
|
}
|
|
// The paragraph has its first run's colour; a run in another colour says so.
|
|
let colour = rgb8(r.block.color);
|
|
if !simple && colour != rgb8(first.color) {
|
|
let [red, green, blue] = colour;
|
|
run = format!("<span style=\"color:#{red:02X}{green:02X}{blue:02X}\">{run}</span>");
|
|
}
|
|
t.push_str(&run);
|
|
}
|
|
let style = if simple { String::new() } else { hex(first.color).map_or(String::new(), |h| format!(" style=\"color:#{h}\"")) };
|
|
match lvl {
|
|
0 => s.push_str(&format!("<p{style}>{t}</p>\n")),
|
|
l => s.push_str(&format!("<h{l}{style}>{t}</h{l}>\n")),
|
|
}
|
|
}
|
|
Item::Table(t) => {
|
|
s.push_str("<table>\n");
|
|
for row in &t.rows {
|
|
s.push_str("<tr>");
|
|
for c in row {
|
|
let mut body = esc(&c.text);
|
|
if c.italic {
|
|
body = format!("<em>{body}</em>");
|
|
}
|
|
if c.bold {
|
|
body = format!("<strong>{body}</strong>");
|
|
}
|
|
let span = if c.span > 1 { format!(" colspan=\"{}\"", c.span) } else { String::new() };
|
|
let style = if simple { String::new() } else { hex(c.color).map_or(String::new(), |h| format!(" style=\"color:#{h}\"")) };
|
|
s.push_str(&format!("<td{span}{style}>{body}</td>"));
|
|
}
|
|
s.push_str("</tr>\n");
|
|
}
|
|
s.push_str("</table>\n");
|
|
}
|
|
Item::Img(im) => {
|
|
let mime = if im.ext == "jpg" { "image/jpeg" } else { "image/png" };
|
|
s.push_str(&format!("<p><img alt=\"\" src=\"data:{mime};base64,{}\"></p>\n", base64(&im.bytes)));
|
|
}
|
|
Item::PageBreak => s.push_str("<hr>\n"),
|
|
}
|
|
}
|
|
s.push_str("</body>\n</html>\n");
|
|
s
|
|
}
|