1use std::fmt::Write as _;
5
6use std::ops::Range;
7use std::sync::Arc;
8
9use ravenrs_core::{Color, ImageData, Point, Rect};
10use ravenrs_criteria::Value;
11
12use crate::base64::encode;
13use crate::model::TextAlign;
14
15#[derive(Clone, Debug, Default, PartialEq)]
17pub struct Document {
18 pub title: String,
20 pub culture: String,
22 pub pages: Vec<Page>,
24 pub errors: Vec<String>,
27}
28
29#[derive(Clone, Debug, Default, PartialEq)]
31pub struct Page {
32 pub width: f32,
34 pub height: f32,
36 pub items: Vec<Item>,
38 pub page_header: Range<usize>,
41 pub page_footer: Range<usize>,
43}
44
45#[derive(Clone, Debug, PartialEq)]
47#[non_exhaustive]
48pub enum Item {
49 Rect {
51 rect: Rect,
53 fill: Option<Color>,
55 stroke: Option<(f32, Color)>,
57 radius: f32,
59 },
60 Ellipse {
62 rect: Rect,
64 fill: Option<Color>,
66 stroke: Option<(f32, Color)>,
68 },
69 Line {
71 from: Point,
73 to: Point,
75 width: f32,
77 color: Color,
79 },
80 Text(TextBlock),
82 Image {
84 rect: Rect,
86 clip: Rect,
88 image: DocumentImage,
90 },
91 Vector {
94 rect: Rect,
96 shapes: Vec<crate::vector::Shape>,
98 },
99 Link {
101 rect: Rect,
103 url: String,
105 },
106 Bookmark {
108 title: String,
110 level: u32,
112 y: f32,
114 },
115}
116
117#[derive(Clone, Copy, Debug, PartialEq, Eq)]
119#[non_exhaustive]
120pub enum ImageFormat {
121 Png,
123 Jpeg,
125}
126
127#[derive(Clone, Debug, PartialEq)]
130pub struct DocumentImage {
131 pixels: ImageData,
132 encoded: Arc<[u8]>,
133 format: ImageFormat,
134 channels: u8,
135}
136
137impl DocumentImage {
138 pub(crate) fn new(pixels: ImageData, encoded: Arc<[u8]>, format: ImageFormat, channels: u8) -> Self {
139 Self { pixels, encoded, format, channels }
140 }
141
142 pub fn pixels(&self) -> &ImageData {
144 &self.pixels
145 }
146
147 pub fn encoded(&self) -> &[u8] {
149 &self.encoded
150 }
151
152 pub fn format(&self) -> ImageFormat {
154 self.format
155 }
156
157 pub fn channels(&self) -> u8 {
159 self.channels
160 }
161}
162
163#[derive(Clone, Debug, PartialEq)]
165pub struct TextBlock {
166 pub clip: Rect,
168 pub lines: Vec<PlacedLine>,
170 pub size: f32,
172 pub line_height: f32,
174 pub bold: bool,
176 pub color: Color,
178 pub align: TextAlign,
180 pub value: Option<Value>,
183 pub format: Option<String>,
185}
186
187#[derive(Clone, Debug, PartialEq)]
189pub struct PlacedLine {
190 pub text: String,
192 pub x: f32,
194 pub top: f32,
196 pub baseline: f32,
198 pub width: f32,
200}
201
202impl Document {
203 pub fn page_text(&self, page: usize) -> Vec<String> {
206 let Some(p) = self.pages.get(page) else { return Vec::new() };
207 let mut lines: Vec<(f32, f32, String)> = p
208 .items
209 .iter()
210 .filter_map(|i| if let Item::Text(t) = i { Some(t) } else { None })
211 .flat_map(|t| t.lines.iter().map(|l| (l.baseline, l.x, l.text.clone())))
212 .collect();
213 lines.sort_by(|a, b| a.0.total_cmp(&b.0).then(a.1.total_cmp(&b.1)));
214 lines.into_iter().map(|(_, _, s)| s).collect()
215 }
216
217 pub fn page_contains(&self, page: usize, needle: &str) -> bool {
219 let needle = needle.to_lowercase();
220 self.page_text(page).iter().any(|l| l.to_lowercase().contains(&needle))
221 }
222
223 pub fn page_svg(&self, page: usize) -> String {
226 let Some(p) = self.pages.get(page) else { return String::new() };
227 let mut s = String::new();
228 let _ = write!(
229 s,
230 r##"<svg xmlns="http://www.w3.org/2000/svg" width="{w}pt" height="{h}pt" viewBox="0 0 {w} {h}"><rect width="{w}" height="{h}" fill="#fff"/>"##,
231 w = p.width,
232 h = p.height
233 );
234 for item in &p.items {
235 match item {
236 Item::Rect { rect, fill, stroke, radius } => {
237 let _ = write!(
238 s,
239 r#"<rect x="{}" y="{}" width="{}" height="{}" rx="{radius}" fill="{}"{}/>"#,
240 rect.x(),
241 rect.y(),
242 rect.width(),
243 rect.height(),
244 fill.map_or("none".into(), css),
245 stroke_attr(*stroke)
246 );
247 }
248 Item::Ellipse { rect, fill, stroke } => {
249 let c = rect.center();
250 let _ = write!(
251 s,
252 r#"<ellipse cx="{}" cy="{}" rx="{}" ry="{}" fill="{}"{}/>"#,
253 c.x,
254 c.y,
255 rect.width() / 2.0,
256 rect.height() / 2.0,
257 fill.map_or("none".into(), css),
258 stroke_attr(*stroke)
259 );
260 }
261 Item::Line { from, to, width, color } => {
262 let _ = write!(
263 s,
264 r#"<line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{}" stroke-width="{width}"/>"#,
265 from.x,
266 from.y,
267 to.x,
268 to.y,
269 css(*color)
270 );
271 }
272 Item::Text(t) => {
273 let overflows = t.lines.iter().any(|l| l.x + l.width > t.clip.max_x() + 0.5);
275 if overflows {
276 let c = t.clip;
277 let _ = write!(
278 s,
279 r#"<svg x="{x}" y="{y}" width="{w}" height="{h}" viewBox="{x} {y} {w} {h}">"#,
280 x = c.x(),
281 y = c.y(),
282 w = c.width(),
283 h = c.height()
284 );
285 }
286 for l in &t.lines {
287 let _ = write!(
289 s,
290 r#"<text x="{}" y="{}" textLength="{}" lengthAdjust="spacingAndGlyphs" xml:space="preserve" font-family="Inter, sans-serif" font-size="{}" font-weight="{}" fill="{}">{}</text>"#,
291 l.x,
292 l.baseline,
293 l.width.max(0.01),
294 t.size,
295 if t.bold { 600 } else { 400 },
296 css(t.color),
297 escape(l.text.trim_end())
298 );
299 }
300 if overflows {
301 s.push_str("</svg>");
302 }
303 }
304 Item::Image { rect, clip, image } => {
305 let mime = match image.format() {
306 ImageFormat::Png => "image/png",
307 ImageFormat::Jpeg => "image/jpeg",
308 };
309 let _ = write!(
311 s,
312 r#"<svg x="{x}" y="{y}" width="{w}" height="{h}" viewBox="{x} {y} {w} {h}"><image x="{}" y="{}" width="{}" height="{}" preserveAspectRatio="none" href="data:{mime};base64,{}"/></svg>"#,
313 rect.x(),
314 rect.y(),
315 rect.width(),
316 rect.height(),
317 encode(image.encoded()),
318 x = clip.x(),
319 y = clip.y(),
320 w = clip.width(),
321 h = clip.height(),
322 );
323 }
324 Item::Link { rect, url } => {
325 let _ = write!(
326 s,
327 r#"<a href="{}"><rect x="{}" y="{}" width="{}" height="{}" fill="transparent"/></a>"#,
328 escape(url).replace('"', """),
329 rect.x(),
330 rect.y(),
331 rect.width(),
332 rect.height(),
333 );
334 }
335 Item::Vector { shapes, .. } => {
336 for shape in shapes {
337 let mut d = String::new();
338 for segment in &shape.path {
339 match segment {
340 crate::vector::PathSegment::Move(p) => {
341 let _ = write!(d, "M{} {}", p.x, p.y);
342 }
343 crate::vector::PathSegment::Line(p) => {
344 let _ = write!(d, "L{} {}", p.x, p.y);
345 }
346 crate::vector::PathSegment::Cubic(a, b, c) => {
347 let _ = write!(d, "C{} {} {} {} {} {}", a.x, a.y, b.x, b.y, c.x, c.y);
348 }
349 crate::vector::PathSegment::Close => d.push('Z'),
350 _ => {}
351 }
352 }
353 let _ = write!(
354 s,
355 r#"<path d="{d}" fill="{}"{}/>"#,
356 shape.fill.map_or("none".into(), css),
357 stroke_attr(shape.stroke)
358 );
359 }
360 }
361 Item::Bookmark { .. } => {}
362 }
363 }
364 s.push_str("</svg>");
365 s
366 }
367
368 pub fn print_html(&self) -> String {
372 let (w, h) = self.pages.first().map_or((595.28, 841.89), |p| (p.width, p.height));
373 let mut s = String::new();
374 let _ = write!(
375 s,
376 "<!doctype html><html><head><meta charset=\"utf-8\"><title>{}</title><style>\
377 @font-face{{font-family:Inter;font-weight:400;src:url(data:font/ttf;base64,{})}}\
378 @font-face{{font-family:Inter;font-weight:600;src:url(data:font/ttf;base64,{})}}\
379 @page{{size:{w}pt {h}pt;margin:0}}\
380 html,body{{margin:0;padding:0;background:#fff}}\
381 svg{{display:block}}\
382 .sheet{{break-after:page;overflow:hidden}}\
383 .sheet:last-child{{break-after:auto}}\
384 </style></head><body>",
385 escape(&self.title),
386 encode(ravenrs_text::inter_font(false)),
387 encode(ravenrs_text::inter_font(true)),
388 );
389 for page in 0..self.pages.len() {
390 s.push_str("<div class=\"sheet\">");
391 s.push_str(&self.page_svg(page));
392 s.push_str("</div>");
393 }
394 s.push_str("</body></html>");
395 s
396 }
397}
398
399fn stroke_attr(stroke: Option<(f32, Color)>) -> String {
400 stroke.map_or(String::new(), |(w, c)| format!(r#" stroke="{}" stroke-width="{w}""#, css(c)))
401}
402
403fn css(c: Color) -> String {
405 format!(
406 "rgba({},{},{},{})",
407 (c.r * 255.0).round() as u8,
408 (c.g * 255.0).round() as u8,
409 (c.b * 255.0).round() as u8,
410 c.a
411 )
412}
413
414fn escape(s: &str) -> String {
415 s.replace('&', "&").replace('<', "<").replace('>', ">")
416}
417
418pub(crate) fn parse_color(s: &str) -> Option<Color> {
420 let hex = s.trim().strip_prefix('#')?;
421 let digit = |i: usize, len: usize| u8::from_str_radix(hex.get(i..i + len)?, 16).ok();
422 let (r, g, b, a) = match hex.len() {
423 3 => (digit(0, 1)? * 17, digit(1, 1)? * 17, digit(2, 1)? * 17, 255),
424 6 => (digit(0, 2)?, digit(2, 2)?, digit(4, 2)?, 255),
425 8 => (digit(0, 2)?, digit(2, 2)?, digit(4, 2)?, digit(6, 2)?),
426 _ => return None,
427 };
428 Some(Color::rgba(f32::from(r) / 255.0, f32::from(g) / 255.0, f32::from(b) / 255.0, f32::from(a) / 255.0))
429}
430
431#[cfg(test)]
432mod tests {
433 use super::*;
434
435 #[test]
436 fn colors_parse() {
437 assert_eq!(parse_color("#ff0000"), Some(Color::rgba(1.0, 0.0, 0.0, 1.0)));
438 assert_eq!(parse_color("#0f0"), Some(Color::rgba(0.0, 1.0, 0.0, 1.0)));
439 assert_eq!(parse_color("#00000080").map(|c| (c.a * 255.0).round()), Some(128.0));
440 assert_eq!(parse_color("red"), None);
441 }
442}