The OpenD Programming Language

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 = &currentState();
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 = &currentState();
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