1use super::shared::{line_height_pt, push_warning, size_with_defaults, EPS};
5use super::{LayoutCtx, LayoutResult, Layoutable};
6use crate::geometry::{Constraints, Rect, Size};
7use crate::render_node::RenderNode;
8use crate::text::{hyphenated_content, text_width_pt, wrap_spans, wrap_text, wrap_text_marking_paragraph_ends, RichLine};
9use crate::warnings::{LayoutWarning, LayoutWarningKind};
10use lightweight_pdf_core::{Align, Element, Line, Overflow, Rect as RectElement, Spacer, Span, Text, TextStyle};
11
12const WIDOW_ORPHAN_N: usize = 2;
15
16fn max_lines_fitting(area_height: f32, lh: f32, available: usize) -> usize {
20 (((area_height + EPS) / lh).floor().max(0.0) as usize).min(available)
21}
22
23struct WrappedLines {
27 lines: Vec<String>,
28 paragraph_end: Vec<bool>,
29}
30
31impl WrappedLines {
32 fn len(&self) -> usize {
33 self.lines.len()
34 }
35}
36
37struct TextLinks {
42 url: Option<String>,
43 anchor: Option<String>,
44 link_to: Option<String>,
45 outline_level: Option<u8>,
46}
47
48impl TextLinks {
49 fn from(text: &Text) -> Self {
50 TextLinks {
51 url: text.url.clone(),
52 anchor: text.anchor.clone(),
53 link_to: text.link_to.clone(),
54 outline_level: text.outline_level,
55 }
56 }
57}
58
59fn text_lines_node(area: Rect, style: TextStyle, wrapped: WrappedLines, lh: f32, links: TextLinks) -> RenderNode {
60 let height = wrapped.lines.len() as f32 * lh;
61 RenderNode::clipped(
62 area,
63 RenderNode::TextLines {
64 area: Rect { height, ..area },
65 style,
66 lines: wrapped.lines,
67 paragraph_end: wrapped.paragraph_end,
68 line_height_pt: lh,
69 url: links.url,
70 anchor: links.anchor,
71 link_to: links.link_to,
72 outline_level: links.outline_level,
73 },
74 )
75}
76
77impl Layoutable for Text {
78 fn measure(&self, ctx: &LayoutCtx, constraints: Constraints) -> Size {
79 let width = self.common.width.unwrap_or(constraints.max_width);
80 if let Some(spans) = &self.spans {
81 let rich_lines = wrap_spans(ctx.resolver, spans, width);
82 let actual_width = rich_lines.iter().map(|l| rich_line_width(ctx.resolver, l)).fold(0.0f32, f32::max);
83 let total_height: f32 = rich_lines.iter().map(|l| l.height).sum();
84 return Size {
85 width: self.common.width.unwrap_or(actual_width.min(width)),
86 height: self.common.height.unwrap_or(total_height),
87 };
88 }
89 let content = hyphenated_content(self);
90 let lines = wrap_text(ctx.resolver, &self.style, &content, width);
91 let lh = line_height_pt(&self.style);
92 let actual_width = lines
93 .iter()
94 .map(|l| text_width_pt(ctx.resolver, self.style.font, self.style.size, l))
95 .fold(0.0f32, f32::max);
96 Size {
97 width: self.common.width.unwrap_or(actual_width.min(width)),
98 height: self.common.height.unwrap_or(lines.len() as f32 * lh),
99 }
100 }
101
102 fn layout(&self, ctx: &LayoutCtx, area: Rect, warnings: &mut Vec<LayoutWarning>, page: usize) -> LayoutResult {
103 if let Some(spans) = &self.spans {
104 return layout_rich_text(self, spans, ctx, area, warnings, page);
105 }
106 push_missing_glyph_warnings(ctx, &self.style, &self.content, warnings, page);
107 let content = hyphenated_content(self);
108 let (lines, paragraph_end) = wrap_text_marking_paragraph_ends(ctx.resolver, &self.style, &content, area.width);
109 let wrapped = WrappedLines { lines, paragraph_end };
110 let lh = line_height_pt(&self.style);
111 let total_height = wrapped.len() as f32 * lh;
112
113 if total_height <= area.height + EPS || wrapped.len() <= 1 {
114 if total_height > area.height + EPS {
115 push_warning(warnings, LayoutWarningKind::TextClipped, page, text_clipped_hint(&self.content));
116 }
117 return LayoutResult::Fit(text_lines_node(area, self.style, wrapped, lh, TextLinks::from(self)));
118 }
119
120 if self.common.height.is_some() {
126 return LayoutResult::Fit(layout_text_fixed_overflow(self, ctx, area, wrapped, lh, warnings, page));
127 }
128
129 let max_lines_by_height = max_lines_fitting(area.height, lh, wrapped.len());
130
131 let mut split_at = max_lines_by_height;
132 if wrapped.len() < 2 * WIDOW_ORPHAN_N {
133 split_at = 0;
135 } else if split_at < WIDOW_ORPHAN_N {
136 split_at = 0;
138 } else if wrapped.len() - split_at < WIDOW_ORPHAN_N {
139 let adjusted = wrapped.len().saturating_sub(WIDOW_ORPHAN_N);
141 split_at = if adjusted >= WIDOW_ORPHAN_N { adjusted } else { 0 };
142 }
143
144 if split_at == 0 {
145 return LayoutResult::Split {
146 current: RenderNode::Empty,
147 remainder: Element::Text(self.clone()),
148 };
149 }
150
151 let (current_lines, remainder_lines) = wrapped.lines.split_at(split_at);
152 let (current_paragraph_end, _) = wrapped.paragraph_end.split_at(split_at);
153 let current = text_lines_node(
154 Rect {
155 height: current_lines.len() as f32 * lh,
156 ..area
157 },
158 self.style,
159 WrappedLines {
160 lines: current_lines.to_vec(),
161 paragraph_end: current_paragraph_end.to_vec(),
162 },
163 lh,
164 TextLinks::from(self),
165 );
166 let remainder_text = remainder_lines.join(" ");
167 let mut remainder = self.clone();
168 remainder.content = remainder_text;
169 remainder.outline_level = None;
173 LayoutResult::Split {
174 current,
175 remainder: Element::Text(remainder),
176 }
177 }
178}
179
180fn rich_line_width(resolver: &dyn crate::font_resolver::FontResolver, line: &RichLine) -> f32 {
191 let mut width = 0.0f32;
192 for (i, word) in line.words.iter().enumerate() {
193 if i > 0 {
194 width += text_width_pt(resolver, word.style.font, word.style.size, " ");
195 }
196 width += text_width_pt(resolver, word.style.font, word.style.size, &word.text);
197 }
198 width
199}
200
201fn rich_text_lines_node(area: Rect, align: Align, lines: Vec<RichLine>) -> RenderNode {
202 let height: f32 = lines.iter().map(|l| l.height).sum();
203 RenderNode::clipped(
204 area,
205 RenderNode::RichTextLines {
206 area: Rect { height, ..area },
207 align,
208 lines,
209 },
210 )
211}
212
213fn rebuild_spans_from_lines(lines: &[RichLine]) -> Vec<Span> {
219 let mut spans: Vec<Span> = Vec::new();
220 for line in lines {
221 for word in &line.words {
222 match spans.last_mut() {
223 Some(last) if last.style == word.style => {
224 last.text.push(' ');
225 last.text.push_str(&word.text);
226 }
227 _ => spans.push(Span::new(word.text.clone(), word.style)),
228 }
229 }
230 }
231 spans
232}
233
234fn layout_rich_text(
235 text: &Text,
236 spans: &[Span],
237 ctx: &LayoutCtx,
238 area: Rect,
239 warnings: &mut Vec<LayoutWarning>,
240 page: usize,
241) -> LayoutResult {
242 for span in spans {
243 push_missing_glyph_warnings(ctx, &span.style, &span.text, warnings, page);
244 }
245 let rich_lines = wrap_spans(ctx.resolver, spans, area.width);
246 let heights: Vec<f32> = rich_lines.iter().map(|l| l.height).collect();
247 let total_height: f32 = heights.iter().sum();
248 let n = rich_lines.len();
249
250 if total_height <= area.height + EPS || n <= 1 {
251 if total_height > area.height + EPS {
252 push_warning(warnings, LayoutWarningKind::TextClipped, page, text_clipped_hint(&text.content));
253 }
254 return LayoutResult::Fit(rich_text_lines_node(area, text.style.align, rich_lines));
255 }
256
257 if text.common.height.is_some() {
258 let mut kept = Vec::new();
262 let mut acc = 0.0f32;
263 for line in rich_lines {
264 if acc + line.height > area.height + EPS && !kept.is_empty() {
265 break;
266 }
267 acc += line.height;
268 kept.push(line);
269 }
270 if kept.len() < n {
271 push_warning(warnings, LayoutWarningKind::TextClipped, page, text_clipped_hint(&text.content));
272 }
273 return LayoutResult::Fit(rich_text_lines_node(area, text.style.align, kept));
274 }
275
276 let mut split_at = 0;
280 let mut acc = 0.0f32;
281 for (i, h) in heights.iter().enumerate() {
282 if acc + h > area.height + EPS {
283 break;
284 }
285 acc += h;
286 split_at = i + 1;
287 }
288 if n < 2 * WIDOW_ORPHAN_N || split_at < WIDOW_ORPHAN_N {
289 split_at = 0;
291 } else if n - split_at < WIDOW_ORPHAN_N {
292 let adjusted = n.saturating_sub(WIDOW_ORPHAN_N);
293 split_at = if adjusted >= WIDOW_ORPHAN_N { adjusted } else { 0 };
294 }
295
296 if split_at == 0 {
297 return LayoutResult::Split {
298 current: RenderNode::Empty,
299 remainder: Element::Text(text.clone()),
300 };
301 }
302
303 let (current_lines, remainder_lines) = rich_lines.split_at(split_at);
304 let current_height: f32 = current_lines.iter().map(|l| l.height).sum();
305 let current = rich_text_lines_node(
306 Rect {
307 height: current_height,
308 ..area
309 },
310 text.style.align,
311 current_lines.to_vec(),
312 );
313
314 let mut remainder = text.clone();
315 remainder.spans = Some(Box::new(rebuild_spans_from_lines(remainder_lines)));
316 LayoutResult::Split {
317 current,
318 remainder: Element::Text(remainder),
319 }
320}
321
322fn layout_text_fixed_overflow(
328 text: &Text,
329 ctx: &LayoutCtx,
330 area: Rect,
331 wrapped: WrappedLines,
332 lh: f32,
333 warnings: &mut Vec<LayoutWarning>,
334 page: usize,
335) -> RenderNode {
336 let max_lines = max_lines_fitting(area.height, lh, wrapped.len());
337 if max_lines >= wrapped.len() {
338 return text_lines_node(area, text.style, wrapped, lh, TextLinks::from(text));
339 }
340 push_warning(warnings, LayoutWarningKind::TextClipped, page, text_clipped_hint(&text.content));
341 let take = if text.common.overflow == Overflow::Ellipsis {
342 max_lines.max(1).min(wrapped.len())
343 } else {
344 max_lines
345 };
346 let mut kept: Vec<String> = wrapped.lines.into_iter().take(take).collect();
347 let mut kept_paragraph_end: Vec<bool> = wrapped.paragraph_end.into_iter().take(take).collect();
348 if text.common.overflow == Overflow::Ellipsis {
349 if let Some(last) = kept.last_mut() {
350 *last = fit_with_ellipsis(ctx, &text.style, last, area.width);
351 }
352 if let Some(last) = kept_paragraph_end.last_mut() {
355 *last = true;
356 }
357 }
358 text_lines_node(
359 area,
360 text.style,
361 WrappedLines {
362 lines: kept,
363 paragraph_end: kept_paragraph_end,
364 },
365 lh,
366 TextLinks::from(text),
367 )
368}
369
370fn fit_with_ellipsis(ctx: &LayoutCtx, style: &TextStyle, line: &str, max_width: f32) -> String {
373 let mut chars: Vec<char> = line.chars().collect();
374 loop {
375 let candidate: String = chars.iter().collect::<String>() + "…";
376 if text_width_pt(ctx.resolver, style.font, style.size, &candidate) <= max_width || chars.is_empty() {
377 return candidate;
378 }
379 chars.pop();
380 }
381}
382
383fn push_missing_glyph_warnings(ctx: &LayoutCtx, style: &TextStyle, content: &str, warnings: &mut Vec<LayoutWarning>, page: usize) {
391 let metrics = ctx.resolver.metrics(style.font);
392 for ch in content.chars() {
393 if ch.is_whitespace() || metrics.has_glyph(ch) {
394 continue;
395 }
396 let kind = LayoutWarningKind::MissingGlyph { ch, font: style.font };
397 if !warnings.iter().any(|w| w.kind == kind) {
398 push_warning(warnings, kind, page, format!("missing glyph for {ch:?}"));
399 }
400 }
401}
402
403fn text_clipped_hint(content: &str) -> String {
404 format!("Text \"{}\"", truncate_hint(content))
405}
406
407fn truncate_hint(s: &str) -> String {
408 if s.len() > 24 {
409 format!("{}…", &s[..24])
410 } else {
411 s.to_string()
412 }
413}
414
415impl Layoutable for Spacer {
422 fn measure(&self, _ctx: &LayoutCtx, _constraints: Constraints) -> Size {
423 Size {
424 width: self.size,
425 height: self.size,
426 }
427 }
428
429 fn layout(&self, _ctx: &LayoutCtx, _area: Rect, _warnings: &mut Vec<LayoutWarning>, _page: usize) -> LayoutResult {
430 LayoutResult::Fit(RenderNode::Empty)
431 }
432}
433
434impl Layoutable for Line {
439 fn measure(&self, _ctx: &LayoutCtx, constraints: Constraints) -> Size {
440 size_with_defaults(&self.common, constraints, self.thickness)
441 }
442
443 fn layout(&self, _ctx: &LayoutCtx, area: Rect, warnings: &mut Vec<LayoutWarning>, page: usize) -> LayoutResult {
444 if self.thickness > area.height + EPS {
445 push_warning(warnings, LayoutWarningKind::ContentOverflow, page, "Line");
446 }
447 let y_mid = area.y + (self.thickness / 2.0).min(area.height);
448 let node = RenderNode::Line {
449 x1: area.x,
450 y1: y_mid,
451 x2: area.x + area.width,
452 y2: y_mid,
453 thickness: self.thickness,
454 color: self.color,
455 };
456 LayoutResult::Fit(RenderNode::clipped(area, node))
457 }
458}
459
460impl Layoutable for RectElement {
465 fn measure(&self, _ctx: &LayoutCtx, constraints: Constraints) -> Size {
466 size_with_defaults(&self.common, constraints, 0.0)
467 }
468
469 fn layout(&self, _ctx: &LayoutCtx, area: Rect, _warnings: &mut Vec<LayoutWarning>, _page: usize) -> LayoutResult {
470 let node = RenderNode::Rect {
471 area,
472 background: self.common.background,
473 border: self.common.border,
474 corner_radius: self.common.corner_radius,
475 };
476 LayoutResult::Fit(RenderNode::clipped(area, node))
477 }
478}