1 /**
2 Flow document.
3
4 Copyright: Guillaume Piolat 2022.
5 License: $(LINK2 http://www.boost.org/LICENSE_1_0.txt, Boost License 1.0)
6 */
7 module printed.flow.document;
8
9 import std.conv: to;
10 import printed.canvas.irenderer;
11 import printed.canvas.image;
12 import printed.flow.style;
13
14 /// A Flow Document produces output without any box model, in a streamed manner.
15 /// If something fits, it is included.
16 /// Honestly, it's already complicated and having boxes and defering rendering is probably better
17 /// for better results.
18 /// For example, this rendere can't ever support hyphenation or text justifying.
19 /// The interface is thought to be able to render Markdown quickly.
20 interface IFlowDocument
21 {
22 /// Output text.
23 void text(const(char)[] s);
24
25 /// Line break.
26 void br();
27
28 /// Next page.
29 void pageSkip();
30
31 /// Enter <h1> title.
32 void enterH1();
33
34 /// Exit </h1> title.
35 void exitH1();
36
37 /// Enter <h2> title.
38 void enterH2();
39
40 /// Exit </h2> title.
41 void exitH2();
42
43 /// Enter <h3> title.
44 void enterH3();
45
46 /// Exit </h3> title.
47 void exitH3();
48
49 /// Enter <h4> title.
50 void enterH4();
51
52 /// Exit </h4> title.
53 void exitH4();
54
55 /// Enter <h5> title.
56 void enterH5();
57
58 /// Exit </h5> title.
59 void exitH5();
60
61 /// Enter <h6> title.
62 void enterH6();
63
64 /// Exit </h6> title.
65 void exitH6();
66
67 /// Enter <b>.
68 void enterB();
69
70 /// Exit </b>.
71 void exitB();
72
73 /// Enter <strong>.
74 void enterStrong();
75
76 /// Exit </strong>.
77 void exitStrong();
78
79 /// Enter <i>.
80 void enterI();
81
82 /// Exit </i>.
83 void exitI();
84
85 /// Enter <em>.
86 void enterEm();
87
88 /// Exit </em>.
89 void exitEm();
90
91 /// Enter <p>.
92 void enterParagraph();
93
94 /// Exit </p>.
95 void exitParagraph();
96
97 /// Enter <pre>.
98 void enterPre();
99
100 /// Exit </pre>.
101 void exitPre();
102
103 /// Enter <code>.
104 void enterCode();
105
106 /// Exit </code>.
107 void exitCode();
108
109 /// Enter <ol>.
110 void enterOrderedList();
111
112 /// Exit </ol>.
113 void exitOrderedList();
114
115 /// Enter <ul>.
116 void enterUnorderedList();
117
118 /// Exit </ul>.
119 void exitUnorderedList();
120
121 /// Enter <li>.
122 void enterListItem();
123
124 /// Exit </li>.
125 void exitListItem();
126
127 /// Enter <img>.
128 void enterImage(const(char)[] relativePath);
129
130 /// Exit </img>.
131 void exitImage();
132
133 /// You MUST make that call before getting the bytes output of the renderer.
134 /// No subsequent can be made with that `IFlowDocument`.
135 void finalize();
136 }
137
138 /// Concrete implementation of `IFlowDocument` using a `
139 class FlowDocument : IFlowDocument
140 {
141 /// A `FlowDocument` needs an already created renderer, and style options.
142 this(IRenderingContext2D renderer, StyleOptions options = StyleOptions.init)
143 {
144 _W = renderer.pageWidth();
145 _H = renderer.pageHeight();
146 _r = renderer;
147 _o = options;
148
149 // Create default state (will be _stateStack[0] throughout)
150 int listItemNumber = 0;
151 float leftMarginMm = _o.pageLeftMarginMm;
152 _stateStack ~= State(_o.color,
153 _o.fontSizePt,
154 _o.fontFace,
155 _o.fontWeight,
156 _o.fontStyle,
157 _o.textAlign,
158 ListStyleType.disc,
159 listItemNumber,
160 leftMarginMm);
161 decoratePage();
162 resetCursorTopLeft();
163 }
164
165 // Each word is split independently.
166 // \n is a special character for forcing a line break.
167 override void text(const(char)[] s)
168 {
169 // TODO: preserve spaces in <pre>, CSS white-space: pre;
170 string[] words = splitIntoWords(s);
171
172 foreach(size_t i, word; words)
173 {
174 outputWord(word);
175 }
176 }
177
178 override void br()
179 {
180 _cursorX = currentState.leftMargin;
181
182 TextMetrics m = _r.measureText("A");
183 _cursorY += m.lineGap;
184 checkPageEnded();
185 }
186
187 override void pageSkip()
188 {
189 _r.newPage;
190 _pageCount += 1;
191 decoratePage();
192 resetCursorTopLeft();
193 }
194
195 override void enterH1()
196 {
197 enterStyle(_o.h1);
198 }
199
200 override void exitH1()
201 {
202 exitStyle(_o.h1);
203 }
204
205 override void enterH2()
206 {
207 enterStyle(_o.h2);
208 }
209
210 override void exitH2()
211 {
212 exitStyle(_o.h2);
213 }
214
215 override void enterH3()
216 {
217 enterStyle(_o.h3);
218 }
219
220 override void exitH3()
221 {
222 exitStyle(_o.h3);
223 }
224
225 override void enterH4()
226 {
227 enterStyle(_o.h4);
228 }
229
230 override void exitH4()
231 {
232 exitStyle(_o.h4);
233 }
234
235 override void enterH5()
236 {
237 enterStyle(_o.h5);
238 }
239
240 override void exitH5()
241 {
242 exitStyle(_o.h5);
243 }
244
245 override void enterH6()
246 {
247 enterStyle(_o.h6);
248 }
249
250 override void exitH6()
251 {
252 exitStyle(_o.h6);
253 }
254
255 override void enterB()
256 {
257 enterStyle(_o.b);
258 }
259
260 override void exitB()
261 {
262 exitStyle(_o.b);
263 }
264
265 override void enterStrong()
266 {
267 enterStyle(_o.strong);
268 }
269
270 override void exitStrong()
271 {
272 exitStyle(_o.strong);
273 }
274
275 override void enterI()
276 {
277 enterStyle(_o.i);
278 }
279
280 override void exitI()
281 {
282 exitStyle(_o.i);
283 }
284
285 override void enterEm()
286 {
287 enterStyle(_o.em);
288 }
289
290 override void exitEm()
291 {
292 exitStyle(_o.em);
293 }
294
295 override void enterParagraph()
296 {
297 enterStyle(_o.p);
298 _cursorX += _o.paragraphTextIndentMm;
299 }
300
301 override void exitParagraph()
302 {
303 exitStyle(_o.p);
304 }
305
306 override void enterPre()
307 {
308 enterStyle(_o.pre);
309 }
310
311 override void exitPre()
312 {
313 exitStyle(_o.pre);
314 }
315
316 override void enterCode()
317 {
318 enterStyle(_o.code);
319 }
320
321 override void exitCode()
322 {
323 exitStyle(_o.code);
324 }
325
326 override void enterOrderedList()
327 {
328 enterStyle(_o.ol);
329 }
330
331 override void exitOrderedList()
332 {
333 exitStyle(_o.ol);
334 }
335
336 override void enterUnorderedList()
337 {
338 enterStyle(_o.ul);
339 }
340
341 override void exitUnorderedList()
342 {
343 exitStyle(_o.ul);
344 }
345
346 override void enterListItem()
347 {
348 enterStyle(_o.li);
349 }
350
351 override void exitListItem()
352 {
353 exitStyle(_o.li);
354 }
355
356 void enterImage(const(char)[] relativePath)
357 {
358 enterStyle(_o.img);
359 Image image = loadImageLazily(relativePath);
360
361 // hard-wired center in page
362 float w = image.printWidth();
363 float h = image.printHeight();
364
365 float maxWidth = _W - _o.pageLeftMarginMm - _o.pageRightMarginMm;
366
367 // Can't exceed available page width.
368 if (w > maxWidth)
369 {
370 h *= (maxWidth / w);
371 w = maxWidth;
372 }
373
374 if (remainPageHeight() < h)
375 pageSkip();
376
377 _r.drawImage(image, (_W - w) / 2, _cursorY, w, h);
378 _cursorY += h;
379 _lastBoxY = _cursorY;
380 }
381
382 void exitImage()
383 {
384 exitStyle(_o.img);
385 }
386
387 override void finalize()
388 {
389 _finalized = true;
390 assert(_stateStack.length == 1); // must close any tag entry
391 _stateStack = [];
392 }
393
394 void checkPageEnded()
395 {
396 if (_cursorY >= _H - _o.pageBottomMarginMm)
397 {
398 pageSkip();
399 }
400 }
401
402 private:
403 // 2D Renderer.
404 IRenderingContext2D _r;
405
406 // Document page width (in mm)
407 float _W;
408
409 // Document page height (in mm)
410 float _H;
411
412 // Style options.
413 StyleOptions _o;
414
415 // position of next thing thing to include (in millimeters)
416 float _cursorX;
417 float _cursorY;
418
419 // position of bottom-right of the last box inserted,
420 // not counting the margins
421 float _lastBoxX;
422 float _lastBoxY;
423
424 int _pageCount = 1;
425 bool _finalized = false;
426
427 // called when page is created
428 void decoratePage()
429 {
430 _r.save();
431 if (_o.onEnterPage !is null) _o.onEnterPage(_r, _pageCount);
432 _r.restore();
433 }
434
435 void resetCursorTopLeft()
436 {
437 _lastBoxX = 0;
438 _lastBoxY = 0;
439 _cursorX = currentState.leftMargin;
440 _cursorY = _o.pageTopMarginMm;
441 }
442
443 // Insert word s, + a whitespace ' ' afterwards.
444 void outputWord(const(char)[] s)
445 {
446 TextMetrics metricsWithoutSpace = _r.measureText(s);
447 TextMetrics metricsWithSpace = _r.measureText(s ~ ' ');
448
449 float bbright = metricsWithoutSpace.width; // TODO: have correct actualBoundingBoxRight;
450 float horzAdvance = metricsWithSpace.width;
451
452 // Will it fit? Trailing space doesn't cause breaking a line.
453 bool fit = _cursorX + bbright < _W - _o.pageRightMarginMm;
454 if (!fit)
455 br();
456
457 _r.fillText(s, _cursorX, _cursorY);
458 _lastBoxX = _cursorX + bbright;
459 _lastBoxY = _cursorY + metricsWithoutSpace.fontBoundingBoxDescent;
460
461 _cursorX += horzAdvance;
462 if (_cursorX >= _W - _o.pageRightMarginMm)
463 {
464 br(); // line break
465 }
466 }
467
468 // State management.
469 // At any point there must be at least one item in here.
470 // The last item holds the current font size.
471
472 static struct State
473 {
474 string color;
475 float fontSize;
476 string fontFace;
477 FontWeight fontWeight;
478 FontStyle fontStyle;
479 TextAlign textAlign;
480 ListStyleType listStyleType;
481 int listItemNumber;
482 float leftMargin; // margin applied by every item, in millimeters
483 }
484
485 State[] _stateStack;
486
487 ref State currentState()
488 {
489 return _stateStack[$-1];
490 }
491
492 // Pushes (context information + fontSize).
493 // This duplicate the top state, but doesn't change it.
494 void pushState()
495 {
496 assert(_stateStack.length != 0);
497 _stateStack ~= _stateStack[$-1];
498 }
499
500 // Pop (context information + fontSize).
501 void popState()
502 {
503 assert(_stateStack.length >= 2);
504 _stateStack = _stateStack[0..$-1];
505
506 // Apply former state to context
507 updateRendererStateWithStyleState();
508 }
509
510 // Apply a TagStyle to the given state.
511 // Set context values with the given state.
512 void enterStyle(const(TagStyle) style)
513 {
514 if (style.display == DisplayStyle.listItem)
515 {
516 currentState().listItemNumber += 1;
517 }
518
519 pushState();
520
521 if (style.listStyleType != ListStyleType.inherit)
522 {
523 // if it's a <ul> or <ol> tag, reset item number.
524 currentState().listItemNumber = 0;
525 }
526
527 // Update state, applying style.
528 State* state = ¤tState();
529 state.fontSize *= style.fontSizeEm;
530 if (style.fontFace !is null) state.fontFace = style.fontFace;
531 if (style.fontWeight != -1) state.fontWeight = style.fontWeight;
532 if (style.fontStyle != -1) state.fontStyle = style.fontStyle;
533 if (style.textAlign != -1) state.textAlign = style.textAlign;
534 if (style.color != "") state.color = style.color;
535 if (style.listStyleType != ListStyleType.inherit) state.listStyleType = style.listStyleType;
536
537 // margin left
538 {
539 state.leftMargin += style.marginLeftMm;
540 _cursorX += style.marginLeftMm;
541 }
542
543 updateRendererStateWithStyleState();
544
545 // Margins: this must be done after fontSize is updated.
546 if (style.hasBlockDisplay())
547 {
548 // ensure top margin
549 float desiredMarginMin = convertPointsToMillimeters(currentState().fontSize * style.marginTopEm);
550
551 // What would be the top-margin if a 'A' were to be drawn here?
552 auto m = _r.measureText("A");
553 float marginTop = _cursorY - m.fontBoundingBoxAscent - _lastBoxY;
554 if (marginTop < desiredMarginMin)
555 {
556 _cursorY += (desiredMarginMin - marginTop);
557 }
558 checkPageEnded();
559 _cursorX = currentState.leftMargin; // Always set at beginning of a line.
560 }
561
562 // list-item display
563 if (style.display == DisplayStyle.listItem)
564 {
565 final switch(state.listStyleType)
566 {
567 case ListStyleType.inherit: break;
568 case ListStyleType.disc:
569 {
570 float emSizeMm = convertPointsToMillimeters( currentState().fontSize );
571 float discRadius = 0.17 * emSizeMm;
572 float discOffsetY = -0.23f * emSizeMm;
573
574 // TODO: implement a disc with a disc, not a rectangle
575 float x = _cursorX + discRadius;
576 float y = _cursorY + discOffsetY;
577 _r.fillRect(x - discRadius, y - discRadius, discRadius * 2, discRadius * 2);
578
579 float advance = _r.measureText("1. ").width;
580 _cursorX += advance;
581 break;
582 }
583 case ListStyleType.decimal: text(to!string(state.listItemNumber) ~ ". "); break;
584 }
585 }
586 }
587
588 void updateRendererStateWithStyleState()
589 {
590 // Update rendering with top state values.
591 State* state = ¤tState();
592 _r.fillStyle(brush(state.color));
593 _r.fontSize(state.fontSize);
594 _r.fontWeight(state.fontWeight);
595 _r.fontStyle(state.fontStyle);
596 _r.fontFace(state.fontFace);
597 _r.textAlign(state.textAlign);
598 }
599
600 void exitStyle(const(TagStyle) style)
601 {
602 if (style.hasBlockDisplay())
603 {
604 // ensure bottom margin
605 float desiredMarginBottomMm = convertPointsToMillimeters(currentState().fontSize * style.marginBottomEm);
606
607 // What would be the bottom-margin if a 'A' were to be drawn here?
608 auto m = _r.measureText("A");
609 float marginBottom = _cursorY - m.fontBoundingBoxAscent - _lastBoxY;
610
611 float insertGap = 0;
612 if (desiredMarginBottomMm > marginBottom)
613 insertGap = (desiredMarginBottomMm - marginBottom);
614
615 _cursorX = currentState.leftMargin;
616 _cursorY += insertGap;
617 checkPageEnded();
618 }
619 popState();
620 }
621
622 alias ImageKey = const(char)[];
623
624 Image[ ImageKey ] _imageCache;
625
626 Image loadImageLazily(ImageKey relativePath)
627 {
628 if (relativePath !in _imageCache)
629 {
630 _imageCache[relativePath] = new Image(relativePath);
631 }
632 return _imageCache[relativePath];
633 }
634
635 float remainPageHeight()
636 {
637 return _H - _o.pageBottomMarginMm - _cursorY;
638 }
639 }
640
641 // Whitespace processing in normal HTML mode:
642 // " - Any sequence of collapsible spaces and tabs immediately preceding or
643 // following a segment break is removed.
644 // - Collapsible segment breaks are transformed for rendering according to
645 // the segment break transformation rules.
646 // - Every collapsible tab is converted to a collapsible space (U+0020).
647 // - Any collapsible space immediately following another collapsible space—even
648 // one outside the boundary of the inline containing that space, provided both
649 // spaces are within the same inline formatting context—is collapsed to have
650 // zero advance width. (It is invisible, but retains its soft wrap opportunity,
651 // if any.)
652 // What we do as a simplifcation => collapse strings of white space into a single char ' '.
653 string[] splitIntoWords(const(char)[] sentence)
654 {
655 // PERF: this is rather bad
656
657 bool isWhitespace(char ch)
658 {
659 return ch == '\n' || ch == ' ' || ch == '\t' || ch == '\r';
660 }
661
662 int index = 0;
663 char peek()
664 {
665 // assert(sentence[index] != '\n');
666 return sentence[index];
667 }
668 void next() { index++; }
669 bool empty() { return index >= sentence.length; }
670
671 bool stateInWord = false;
672
673 string[] res;
674
675 void parseWord()
676 {
677 assert(!empty);
678 while(!empty && isWhitespace(peek))
679 next;
680 if (empty) return;
681 assert(!isWhitespace(peek));
682
683 // start of word is here
684 string word;
685 while(!empty && !isWhitespace(peek))
686 {
687 word ~= peek;
688 next;
689 }
690 // word parsed here, push it
691 res ~= word;
692 }
693
694 while (!empty)
695 {
696 parseWord;
697 }
698 assert(empty);
699 return res;
700 }
701
702