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