├── changelog.md ├── css ├── main.css └── reset.css ├── fonts ├── PlombSans-Bold.woff2 └── PlombSans-Regular.woff2 ├── images └── screenshot.png ├── index.html ├── js ├── AppState.js ├── Feature.js ├── Filter.js ├── Filters.js ├── Font.js ├── Fonts.js ├── Helpers.js ├── Layout.js ├── Line.js ├── Size.js ├── Words.js ├── app.js ├── components │ ├── About.js │ ├── CopyButton.js │ ├── CopyButtonGlobal.js │ ├── DarkModeButton.js │ ├── DeleteButton.js │ ├── DeleteButtonGlobal.js │ ├── DropZone.js │ ├── FeaturesMenu.js │ ├── FilterSelect.js │ ├── FilterSelectGlobal.js │ ├── FontInput.js │ ├── FontSelect.js │ ├── FontSelectGlobal.js │ ├── Footer.js │ ├── Header.js │ ├── IconSpinning.js │ ├── Line.js │ ├── NewLineButton.js │ ├── OptionsMenu.js │ ├── SVG.js │ ├── SVGAnimation.js │ ├── SizeInput.js │ ├── SizeInputGlobal.js │ ├── Specimen.js │ ├── SplashScreen.js │ ├── Tooltip.js │ ├── UpdateButton.js │ ├── UpdateButtonGlobal.js │ └── WidthInput.js ├── harfbuzzjs │ ├── hb.wasm │ └── hbjs.js ├── miniotparser │ └── MiniOTParser.js ├── vendor │ └── mithril.min.js └── wordgenerator │ ├── WordGenerator.js │ ├── WorkerPool.js │ └── worker.js ├── license.md ├── readme.md ├── svg ├── font-files-animation.svg ├── logo.svg └── stack-and-justify-animation.svg └── words ├── dictionaries ├── catalan.json ├── czech.json ├── danish.json ├── dutch.json ├── english.json ├── finnish.json ├── french.json ├── german.json ├── hungarian.json ├── icelandic.json ├── italian.json ├── latin.json ├── norwegian.json ├── polish.json ├── slovak.json └── spanish.json └── wikipedia ├── ca_wikipedia.json ├── cs_wikipedia.json ├── da_wikipedia.json ├── de_wikipedia.json ├── en_wikipedia.json ├── es_wikipedia.json ├── fi_wikipedia.json ├── fr_wikipedia.json ├── hu_wikipedia.json ├── is_wikipedia.json ├── it_wikipedia.json ├── la_wikipedia.json ├── nl_wikipedia.json ├── no_wikipedia.json ├── pl_wikipedia.json └── sk_wikipedia.json /changelog.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 1.10 4 | 5 | ### Added 6 | - Add support for OpenType features 7 | 8 | ### Changed 9 | - Fonts are sorted using font metadatas (usWeightClass, usWidthClass) instead of style names 10 | - Fonts are grouped by family in the font inputs 11 | - Small design fixes in menus and inputs 12 | - Use Harfbuzz instead of Canvas API for measuring strings. 13 | 14 | ### Fixed 15 | - Selected options in the Options menu are now persistent even when not applied (also applies to the Features menu) 16 | 17 | 18 | ## 1.02 19 | - Add basic CSS to improve apparence on mobile devices 20 | - Improve SEO by adding meta description and OG tags 21 | - Fix a bug where the font select inputs were troncated 22 | - Fix the sorting of fonts with numbers as style names 23 | 24 | 25 | ## 1.01 26 | - Improve browser support -------------------------------------------------------------------------------- /css/main.css: -------------------------------------------------------------------------------- 1 | /* Typography */ 2 | 3 | @font-face { 4 | font-family: "Plomb Sans"; 5 | src: url("../fonts/PlombSans-Regular.woff2") format("woff2"); 6 | font-weight: 400; 7 | } 8 | 9 | @font-face { 10 | font-family: "Plomb Sans"; 11 | src: url("../fonts/PlombSans-Bold.woff2") format("woff2"); 12 | font-weight: 700; 13 | } 14 | 15 | :root { 16 | font-family: Plomb Sans; 17 | font-size: 14px; 18 | line-height: 1.2; 19 | } 20 | 21 | .t-big { 22 | font-size: 28px; 23 | line-height: 1.3; 24 | } 25 | 26 | .bold { 27 | font-weight: 700; 28 | } 29 | 30 | @media screen and (max-width: 430px) { 31 | :root { 32 | font-size: 13px; 33 | } 34 | 35 | .t-big { 36 | font-size: 19.5px; 37 | } 38 | } 39 | 40 | 41 | /* Base */ 42 | 43 | html { 44 | display: flex; 45 | min-height: 100vh; 46 | } 47 | 48 | body { 49 | padding: 2rem; 50 | margin: 0; 51 | box-sizing: border-box; 52 | background: var(--bg-color); 53 | color: var(--fg-color); 54 | display: flex; 55 | flex: 1; 56 | min-height: 100vh; 57 | transition: background 0.1s, color 0.2s; 58 | 59 | --bg-color: #FFF; 60 | --fg-color: #000; 61 | --grey-light: #F9F9F9; 62 | --grey: #A0A0A0; 63 | --green: #00b47c; 64 | --green-light: #00e682; 65 | } 66 | 67 | body.dark { 68 | --bg-color: #000; 69 | --fg-color: #FFF; 70 | --grey-light: #171717; 71 | --green: #00e682; 72 | --green-light: #00b47c; 73 | } 74 | 75 | input[disabled] { 76 | -webkit-text-fill-color: var(--fg-color); 77 | } 78 | 79 | #app { 80 | flex: 1; 81 | display: flex; 82 | flex-direction: column; 83 | } 84 | 85 | .main { 86 | position: relative; 87 | flex: 1 88 | } 89 | 90 | @media screen and (max-width: 430px) { 91 | body { 92 | max-width: 100%; 93 | padding: 1.5rem; 94 | } 95 | 96 | #app { 97 | max-width: 100%; 98 | } 99 | } 100 | 101 | 102 | 103 | /* Links */ 104 | 105 | .disabled { 106 | opacity: 0.3; 107 | } 108 | 109 | .disabled:hover { 110 | cursor: default; 111 | color: inherit; 112 | } 113 | 114 | button { 115 | cursor: pointer; 116 | } 117 | 118 | a { 119 | border-bottom: 1px dotted; 120 | cursor: pointer; 121 | } 122 | 123 | a:hover, 124 | button:hover { 125 | color: var(--green); 126 | } 127 | 128 | a:active, 129 | button:active { 130 | color: var(--green-light); 131 | } 132 | 133 | .big-link::after { 134 | content: '↗'; 135 | font-size: 1rem; 136 | margin-left: 0.15rem; 137 | vertical-align: text-top; 138 | } 139 | 140 | 141 | /* Header */ 142 | 143 | .header { 144 | position: relative; 145 | display: grid; 146 | grid-column-gap: 2rem; 147 | grid-template-columns: 2fr 3fr 1fr 1fr 1fr; 148 | padding-bottom: 4rem; 149 | } 150 | 151 | .logo { 152 | display: flex; 153 | align-items: flex-start; 154 | font-weight: 700; 155 | user-select: none; 156 | -webkit-user-select: none; 157 | } 158 | 159 | .logo svg { 160 | position: relative; 161 | top: -20%; 162 | margin-right: 0.75rem; 163 | } 164 | 165 | .logo svg .fill { 166 | transition: 0.15s fill; 167 | } 168 | 169 | body.dark .logo svg .fill { 170 | fill: var(--fg-color); 171 | } 172 | 173 | .drop-message { 174 | user-select: none; 175 | -webkit-user-select: none; 176 | } 177 | 178 | .drop-btn { 179 | white-space: nowrap; 180 | } 181 | 182 | .header-btns { 183 | text-align: right; 184 | user-select: none; 185 | -webkit-user-select: none; 186 | white-space: nowrap; 187 | } 188 | 189 | .dark-mode-btn { 190 | position: relative; 191 | margin-right: 0.5rem; 192 | z-index: 1; 193 | } 194 | 195 | @media screen and (max-width: 430px) { 196 | .header { 197 | display: flex; 198 | flex-wrap: wrap; 199 | justify-content: space-between; 200 | padding-bottom: 0; 201 | } 202 | 203 | .logo { 204 | order: 0; 205 | margin-bottom: 0.5rem; 206 | } 207 | 208 | .header-btns { 209 | order: 1; 210 | } 211 | 212 | .options { 213 | display: none; 214 | } 215 | 216 | .features { 217 | display: none; 218 | } 219 | 220 | .drop-message { 221 | order: 2; 222 | } 223 | } 224 | 225 | /* Footer */ 226 | 227 | .footer { 228 | display: flex; 229 | justify-content: space-between; 230 | margin-top: 4rem; 231 | } 232 | 233 | .footer .credit, 234 | .footer .github { 235 | user-select: none; 236 | -webkit-user-select: none; 237 | } 238 | 239 | @media screen and (max-width: 430px) { 240 | .footer { 241 | margin-top: 0; 242 | flex-wrap: wrap; 243 | } 244 | 245 | .footer .credit { 246 | margin-bottom: 0.75rem; 247 | } 248 | } 249 | 250 | 251 | /* Drop Zone */ 252 | 253 | .drop-zone { 254 | position: fixed; 255 | top: 0; 256 | left: 0; 257 | right: 0; 258 | bottom: 0; 259 | z-index: 10; 260 | visibility: hidden; 261 | opacity: 0; 262 | background-color: var(--green); 263 | transition: visibility 175ms, opacity 175ms; 264 | } 265 | 266 | .drop-zone.active { 267 | opacity: 0.85; 268 | visibility: visible; 269 | } 270 | 271 | 272 | /* Splash Screen */ 273 | 274 | .splash-screen { 275 | border-top: 1px solid; 276 | padding-top: 0.5rem; 277 | margin-top: 3.285rem; 278 | } 279 | 280 | .splash-screen-text { 281 | max-width: 34rem; 282 | } 283 | 284 | .splash-screen svg { 285 | margin-bottom: 0.5rem; 286 | } 287 | 288 | .splash-screen-notice { 289 | margin-top: 1rem; 290 | } 291 | 292 | 293 | /* Specimen */ 294 | 295 | .specimen { 296 | display: grid; 297 | grid-column-gap: 2rem; 298 | grid-template-columns: [left] 1fr [middle] auto [right] 1fr; 299 | } 300 | 301 | .specimen-header { 302 | display: grid; 303 | grid-column: span 3; 304 | grid-template-columns: subgrid; 305 | border-bottom: 1px solid; 306 | } 307 | 308 | .specimen-body { 309 | display: grid; 310 | grid-column: span 3; 311 | grid-template-columns: subgrid; 312 | } 313 | 314 | .specimen-line { 315 | position: relative; 316 | display: grid; 317 | grid-column: span 3; 318 | grid-template-columns: subgrid; 319 | border-bottom: 1px dotted; 320 | cursor: grab; 321 | transition: background-color 0.2s; 322 | } 323 | 324 | .specimen-line:hover { 325 | background-color: var(--grey-light); 326 | 327 | } 328 | 329 | @supports not (grid-template-columns: subgrid) { 330 | .specimen-line, 331 | .specimen-header { 332 | grid-column-gap: 2rem; 333 | grid-template-columns: [left] 1fr [middle] auto [right] 1fr; 334 | } 335 | } 336 | 337 | .specimen-line.dragged * { 338 | visibility: hidden; 339 | } 340 | 341 | .specimen-line.drag-clone { 342 | position: fixed; 343 | background: var(--bg-color); 344 | pointer-events: none; 345 | z-index: 10; 346 | border-top: 1px dotted; 347 | bottom: 1px dotted; 348 | box-shadow: 1px 2px 2px rgba(0,0,0,0.25); 349 | } 350 | 351 | .line-left-col { 352 | grid-column: left; 353 | display: flex; 354 | align-items: flex-start; 355 | padding-top: 1rem; 356 | padding-bottom: 1rem; 357 | user-select: none; 358 | -webkit-user-select: none; 359 | } 360 | 361 | .line-middle-col { 362 | position: relative; 363 | grid-column: middle; 364 | display: flex; 365 | justify-content: center; 366 | } 367 | 368 | .specimen-line .text { 369 | align-self: center; 370 | cursor: text; 371 | transition: opacity 0.15s; 372 | } 373 | 374 | .specimen-line .text.visible { 375 | opacity: 1; 376 | } 377 | 378 | .specimen-line .text.hidden { 379 | opacity: 0; 380 | } 381 | 382 | 383 | .specimen-line .loading { 384 | position: absolute; 385 | top: 0; 386 | left: 0; 387 | width: 100%; 388 | height: 100%; 389 | display: flex; 390 | justify-content: center; 391 | align-items: center; 392 | color: #A0A0A0; 393 | /* background: var(--bg-color);*/ 394 | transition: opacity 0.15s; 395 | pointer-events: none; 396 | user-select: none; 397 | -webkit-user-select: none; 398 | } 399 | 400 | .specimen-line .loading.hidden { 401 | opacity: 0; 402 | } 403 | 404 | .specimen-line .loading.visible { 405 | opacity: 1; 406 | } 407 | 408 | .specimen-line .no-words-found { 409 | position: absolute; 410 | top: 0; 411 | left: 0; 412 | width: 100%; 413 | height: 100%; 414 | display: flex; 415 | justify-content: center; 416 | align-items: center; 417 | white-space: nowrap; 418 | pointer-events: none; 419 | user-select: none; 420 | -webkit-user-select: none; 421 | } 422 | 423 | @keyframes fadeIn { 424 | 0% { 425 | opacity: 0; 426 | } 100% { 427 | opacity: 1; 428 | } 429 | } 430 | 431 | .specimen-line .loading .icon-spinning { 432 | margin-left: 0.35rem; 433 | } 434 | 435 | .line-right-col { 436 | grid-column: right; 437 | display: flex; 438 | justify-content: flex-end; 439 | align-items: flex-start; 440 | padding-top: 1rem; 441 | padding-bottom: 1rem; 442 | user-select: none; 443 | -webkit-user-select: none; 444 | } 445 | 446 | .update-button, 447 | .copy-button { 448 | position: relative; 449 | margin-left: 1rem; 450 | } 451 | 452 | .tooltip { 453 | font-size: 0.95rem; 454 | visibility: hidden; 455 | position: absolute; 456 | top: -2.1rem; 457 | background: var(--bg-color); 458 | padding: 0.2rem 0.35rem; 459 | border: 1px dotted; 460 | border-radius: 0.35rem; 461 | box-shadow: 1px 2px 2px rgba(0,0,0,0.25); 462 | white-space: nowrap; 463 | z-index: 1; 464 | pointer-events: none; 465 | } 466 | 467 | *:hover > .tooltip { 468 | visibility: visible; 469 | } 470 | 471 | .copy-button:hover .copy-tooltip, 472 | .update-button:hover .update-tooltip { 473 | visibility: visible; 474 | } 475 | 476 | .copy-tooltip, 477 | .update-tooltip { 478 | font-size: 0.95rem; 479 | visibility: hidden; 480 | position: absolute; 481 | top: -2.1rem; 482 | right: 0; 483 | background-color: var(--bg-color); 484 | background: var(--bg-color); 485 | padding: 0.2rem 0.35rem; 486 | border: 1px dotted; 487 | border-radius: 0.35rem; 488 | box-shadow: 1px 2px 2px rgba(0,0,0,0.25); 489 | white-space: nowrap; 490 | z-index: 1; 491 | pointer-events: none; 492 | } 493 | 494 | .new-line { 495 | grid-column: span 3; 496 | height: 5rem; 497 | display: flex; 498 | justify-content: center; 499 | align-items: center; 500 | border-bottom: 1px dotted; 501 | cursor: pointer; 502 | user-select: none; 503 | -webkit-user-select: none; 504 | } 505 | 506 | .new-line:hover .new-line-button { 507 | color: var(--green); 508 | } 509 | 510 | /* Font List */ 511 | 512 | .font-items { 513 | position: relative; 514 | min-width: 0; 515 | padding-top: 0.9rem; 516 | padding-bottom: 0.9rem; 517 | } 518 | 519 | .font-items-scroller { 520 | display: flex; 521 | scroll-behavior: smooth; 522 | overflow-x: auto; 523 | overscroll-behavior-x: contain; 524 | -ms-overflow-style: none; /* IE and Edge */ 525 | scrollbar-width: none; /* Firefox */ 526 | } 527 | 528 | .font-items-scroller::-webkit-scrollbar { 529 | display: none; 530 | } 531 | 532 | .font-items-scroller.start { 533 | mask-image: linear-gradient(to left, rgb(0,0,0,0) 0%, rgb(0,0,0,1) 18%, rgb(0,0,0,1) 20%); 534 | } 535 | 536 | .font-items-scroller.middle { 537 | mask-image: linear-gradient(to left, rgb(0,0,0,0) 0%, rgb(0,0,0,1) 18%, rgb(0,0,0,1) 82%, rgb(0,0,0,0) 100%); 538 | } 539 | 540 | .font-items-scroller.end { 541 | mask-image: linear-gradient(to right, rgb(0,0,0,0) 0%, rgb(0,0,0,1) 18%, rgb(0,0,0,1) 20%); 542 | } 543 | 544 | .scroll-left-button { 545 | position: absolute; 546 | top: 0; 547 | height: 100%; 548 | display: flex; 549 | padding-right: 0.5rem; 550 | justify-content: flex-start; 551 | align-items: center; 552 | z-index: 1; 553 | } 554 | 555 | .scroll-right-button { 556 | position: absolute; 557 | top: 0; 558 | right: 0; 559 | height: 100%; 560 | display: flex; 561 | padding-left: 0.5rem; 562 | justify-content: flex-end; 563 | align-items: center; 564 | z-index: 1; 565 | } 566 | 567 | .font-item { 568 | white-space: nowrap; 569 | font-weight: 700; 570 | border: 1px solid var(--bg-color); 571 | user-select: none; 572 | -webkit-user-select: none; 573 | padding: 0.1rem 0.5rem; 574 | border-radius: 1rem; 575 | cursor: default; 576 | } 577 | 578 | .font-item:hover, 579 | .font-item.drag-clone { 580 | border: dotted 1px; 581 | background: var(--bg-color); 582 | color: var(--green); 583 | } 584 | 585 | .font-item.drag-clone { 586 | position: fixed; 587 | left: 0; 588 | pointer-events: none; 589 | } 590 | 591 | .font-item[data-dragged] { 592 | visibility: hidden; 593 | } 594 | 595 | .font-item.loading { 596 | color: #A0A0A0; 597 | } 598 | 599 | .font-item-label { 600 | margin-right: 0.5rem; 601 | } 602 | 603 | .font-item-remove { 604 | cursor: pointer; 605 | } 606 | 607 | .icon-spinning { 608 | animation: spinning 2s linear infinite; 609 | } 610 | 611 | .drop-placeholder { 612 | background: var(--green); 613 | width: 1rem; 614 | margin-right: 1rem; 615 | } 616 | 617 | @keyframes spinning { 618 | from { 619 | transform: rotate(0deg); 620 | } 621 | to { 622 | transform: rotate(360deg); 623 | } 624 | } 625 | 626 | 627 | /* Inputs */ 628 | 629 | input:focus { 630 | outline: 0; 631 | box-shadow: 0 0 0 2px var(--grey); 632 | border-radius: 1px; 633 | } 634 | 635 | .line-count { 636 | white-space: nowrap; 637 | } 638 | 639 | .line-count-label { 640 | margin-right: 1rem; 641 | } 642 | 643 | .line-count-input { 644 | width: 2ch; 645 | text-align: center; 646 | display: inline-block; 647 | margin-left: 0.5rem; 648 | margin-right: 0.5rem; 649 | } 650 | 651 | .size-input { 652 | position: relative; 653 | display: flex; 654 | align-items: start; 655 | margin-right: 3rem; 656 | } 657 | 658 | .size-input-wrapper { 659 | display: flex; 660 | align-items: start; 661 | } 662 | 663 | .size-input-global { 664 | padding-top: 0; 665 | } 666 | 667 | .size-input.disabled button { 668 | cursor: default; 669 | } 670 | 671 | .size-input.disabled button:hover { 672 | color: inherit; 673 | } 674 | 675 | .size-input input { 676 | width: 5ch; 677 | text-align: center; 678 | display: inline-block; 679 | margin-left: 0.5rem; 680 | margin-right: 0.5rem; 681 | user-select: none; 682 | -webkit-user-select: none; 683 | } 684 | 685 | .size-input-lock { 686 | position: absolute; 687 | right: -1rem; 688 | cursor: pointer; 689 | } 690 | 691 | .width-input { 692 | display: flex; 693 | position: relative; 694 | align-items: center; 695 | justify-content: center; 696 | user-select: none; 697 | -webkit-user-select: none; 698 | } 699 | 700 | .width-input::before { 701 | content: '⇤'; 702 | position: absolute; 703 | left: 0; 704 | } 705 | 706 | .width-input::after { 707 | content: '⇥'; 708 | position: absolute; 709 | right: 0; 710 | } 711 | 712 | .width-input input { 713 | position: relative; 714 | border: none; 715 | width: 8ch; 716 | text-align: center; 717 | margin-left: 0.5rem; 718 | margin-right: 0.5rem; 719 | } 720 | 721 | .width-input input.small { 722 | top: -1.5rem; 723 | } 724 | 725 | .width-input-line { 726 | height: 0.75px; 727 | background: currentColor; 728 | flex: 1; 729 | } 730 | 731 | .width-input-handle.left { 732 | width: 0.5rem; 733 | height: 1rem; 734 | position: absolute; 735 | left: 0; 736 | cursor: ew-resize; 737 | z-index: 1; 738 | } 739 | 740 | .width-input-handle.right { 741 | width: 0.5rem; 742 | height: 1rem; 743 | position: absolute; 744 | right: 0; 745 | cursor: ew-resize; 746 | z-index: 1; 747 | } 748 | 749 | .width-input-line-cap-left { 750 | width: 0; 751 | } 752 | 753 | .width-input-line-cap-right { 754 | position: relative; 755 | left: -100%; 756 | } 757 | 758 | .size-slider { 759 | display: flex; 760 | align-items: center; 761 | } 762 | 763 | .size-slider label { 764 | margin-right: 0.5rem; 765 | } 766 | 767 | .select-wrapper { 768 | position: relative; 769 | padding-right: 0.75rem; 770 | white-space: nowrap; 771 | display: inline-block; 772 | cursor: pointer; 773 | } 774 | 775 | .select-wrapper:not(.disabled):hover { 776 | color: var(--green); 777 | } 778 | 779 | select { 780 | appearance: none; 781 | cursor: pointer; 782 | } 783 | 784 | select[disabled] { 785 | cursor: inherit; 786 | } 787 | 788 | .select-wrapper::after { 789 | content: '▿'; 790 | position: absolute; 791 | right: 0; 792 | } 793 | 794 | .case-select { 795 | white-space: nowrap; 796 | } 797 | 798 | .case-select-lock { 799 | position: relative; 800 | margin-right: 0.5rem; 801 | display: inline-block; 802 | } 803 | 804 | .font-select { 805 | position: relative; 806 | margin-right: 2rem; 807 | white-space: nowrap; 808 | } 809 | 810 | .font-select-lock { 811 | position: absolute; 812 | right: -1rem; 813 | top: 0; 814 | } 815 | 816 | .menu-container { 817 | white-space: nowrap; 818 | position: relative; 819 | user-select: none; 820 | -webkit-user-select: none; 821 | } 822 | 823 | .menu { 824 | position: absolute; 825 | top: 1.5rem; 826 | left: -1rem; 827 | background: var(--bg-color); 828 | border: 1px dotted; 829 | border-radius: 0.35rem; 830 | box-shadow: 1px 2px 2px rgba(0,0,0,0.25); 831 | z-index: 3; 832 | max-height: calc(100vh - 5.5rem); 833 | overflow: auto; 834 | } 835 | 836 | .submenu { 837 | padding-top: 0rem; 838 | padding-bottom: 0rem; 839 | } 840 | 841 | .submenu > * { 842 | box-sizing: border-box; 843 | width: 100%; 844 | padding-left: 1rem; 845 | padding-right: 1rem; 846 | } 847 | 848 | .submenu-header { 849 | padding-top: 0.55rem; 850 | padding-bottom: 0.5rem; 851 | position: relative; 852 | display: flex; 853 | justify-content: space-between; 854 | position: sticky; 855 | top: 0; 856 | background: var(--bg-color); 857 | z-index: 1; 858 | } 859 | 860 | .submenu-header::before { 861 | content: ""; 862 | position: absolute; 863 | left: 0; 864 | width: 100%; 865 | top: -1px; 866 | border-bottom: 1px dotted; 867 | border-color: var(--fg-color); 868 | } 869 | 870 | .submenu-header::after { 871 | content: ""; 872 | position: absolute; 873 | left: 0; 874 | width: 100%; 875 | bottom: -1px; 876 | border-bottom: 1px dotted; 877 | border-color: var(--fg-color); 878 | } 879 | 880 | .submenu-toggle { 881 | cursor: pointer; 882 | } 883 | 884 | .submenu-toggle:hover { 885 | color: var(--green); 886 | } 887 | 888 | .submenu-content { 889 | overflow: hidden; 890 | transition: max-height 0.4s; 891 | } 892 | 893 | .submenu-content::before { 894 | content: ""; 895 | display: block; 896 | padding-top: 0.75rem; 897 | } 898 | 899 | .submenu-content::after { 900 | content: ""; 901 | display: block; 902 | padding-bottom: 0.75rem; 903 | } 904 | 905 | .menu-update { 906 | text-align: right; 907 | border-top: 1px dotted; 908 | padding: 0.55rem 1rem 0.5rem; 909 | position: sticky; 910 | bottom: 0; 911 | background: var(--bg-color); 912 | z-index: 1; 913 | } 914 | 915 | .menu-update.disabled button { 916 | cursor: default; 917 | } 918 | 919 | .menu-update.disabled button:hover { 920 | color: inherit; 921 | } 922 | 923 | .checkbox { 924 | display: flex; 925 | margin-bottom: 0.15rem; 926 | } 927 | 928 | input[type="checkbox"] { 929 | appearance: none; 930 | display: none; 931 | } 932 | 933 | .checkbox label { 934 | cursor: pointer; 935 | } 936 | 937 | .checkbox:hover { 938 | color: var(--green); 939 | } 940 | 941 | input[type="checkbox"] + label::before { 942 | content: '☐'; 943 | margin-right: 0.5rem; 944 | } 945 | 946 | input[type="checkbox"]:checked + label::before { 947 | content: '☒'; 948 | margin-right: 0.5rem; 949 | } 950 | 951 | .checkbox label { 952 | width: 100%; 953 | padding-left: 0.25rem; 954 | white-space: nowrap; 955 | } 956 | 957 | .feature-tag { 958 | font-family: monospace; 959 | font-size: 11px; 960 | position: relative; 961 | top: -1px; 962 | text-transform: uppercase; 963 | margin-right: 1em; 964 | } 965 | 966 | /* About */ 967 | 968 | .about { 969 | position: absolute; 970 | width: 100%; 971 | top: 0; 972 | bottom: 0; 973 | background: var(--bg-color); 974 | visibility: hidden; 975 | opacity: 0; 976 | padding-top: 3.285rem; 977 | display: grid; 978 | grid-template-columns: 2fr 3fr 1fr 1fr 1fr; 979 | grid-column-gap: 2rem; 980 | transition: visibility 175ms, opacity 175ms, background 0.1s; 981 | z-index: 2; 982 | } 983 | 984 | .about.open { 985 | visibility: visible; 986 | opacity: 1; 987 | } 988 | 989 | .about svg { 990 | max-width: 100%; 991 | height: auto; 992 | } 993 | 994 | .about-text { 995 | grid-column: span 3; 996 | display: grid; 997 | grid-column-gap: 2rem; 998 | grid-template-columns: 3fr 1fr 1fr; 999 | grid-auto-rows: min-content 1000 | } 1001 | 1002 | .about-text p { 1003 | max-width: 51rem; 1004 | margin-bottom: 2rem; 1005 | grid-column: span 3; 1006 | } 1007 | 1008 | .about-text p.col-1 { 1009 | grid-column: span 1; 1010 | } 1011 | 1012 | .donation-btn { 1013 | border: 1px dotted; 1014 | border-radius: 7px; 1015 | padding: 0 8px 2px 11px; 1016 | display: inline-block; 1017 | margin-top: 8px; 1018 | } 1019 | 1020 | .donation-btn::before { 1021 | content: "$"; 1022 | font-size: 14px; 1023 | position: relative; 1024 | top: -4px; 1025 | margin-right: 6px; 1026 | } 1027 | 1028 | @media screen and (max-width: 430px) { 1029 | .about { 1030 | padding-top: 0; 1031 | display: flex; 1032 | flex-wrap: wrap; 1033 | } 1034 | 1035 | .about svg { 1036 | max-width: 50%; 1037 | stroke-width: 2; 1038 | } 1039 | 1040 | .about-text p { 1041 | margin-bottom: 1rem; 1042 | } 1043 | 1044 | .about-text p.col-1 { 1045 | grid-column: span 2; 1046 | } 1047 | } -------------------------------------------------------------------------------- /css/reset.css: -------------------------------------------------------------------------------- 1 | h1, h2, h3, h4, h5, h6 { 2 | margin: 0; 3 | font-size: inherit; 4 | font-weight: inherit; 5 | } 6 | 7 | fieldset, 8 | p { 9 | border: 0; 10 | padding: 0; 11 | margin: 0; 12 | } 13 | 14 | a, a:hover, a:active, a:visited { 15 | color: inherit; 16 | text-decoration: none; 17 | } 18 | 19 | em { 20 | font-style: normal; 21 | } 22 | 23 | button { 24 | color: inherit; 25 | font-size: inherit; 26 | font-family: inherit; 27 | margin: 0; 28 | padding: 0; 29 | box-shadow: none; 30 | border: none; 31 | background-color: transparent; 32 | } 33 | 34 | select { 35 | background: transparent; 36 | color: inherit; 37 | border: 0; 38 | padding: 0; 39 | font-family: inherit; 40 | font-size: inherit; 41 | } 42 | 43 | input { 44 | background: inherit; 45 | color: inherit; 46 | padding: 0; 47 | font-size: inherit; 48 | font-family: inherit; 49 | line-height: inherit; 50 | } 51 | 52 | input[type="number"], 53 | input[type="text"] { 54 | border: 0; 55 | } 56 | 57 | input[type=number] { 58 | -moz-appearance: textfield; 59 | appearance: textfield; 60 | } 61 | 62 | input[type=number]::-webkit-inner-spin-button, 63 | input[type=number]::-webkit-outer-spin-button { 64 | -webkit-appearance: none; 65 | margin: 0; 66 | } -------------------------------------------------------------------------------- /fonts/PlombSans-Bold.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maxesnee/stack-and-justify/fb54fe697996d5d5592ad5d98e0d632213b3417d/fonts/PlombSans-Bold.woff2 -------------------------------------------------------------------------------- /fonts/PlombSans-Regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maxesnee/stack-and-justify/fb54fe697996d5d5592ad5d98e0d632213b3417d/fonts/PlombSans-Regular.woff2 -------------------------------------------------------------------------------- /images/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maxesnee/stack-and-justify/fb54fe697996d5d5592ad5d98e0d632213b3417d/images/screenshot.png -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Stack & Justify 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 30 | 31 | 32 | 33 |
34 | 35 | 36 | 44 | 45 | -------------------------------------------------------------------------------- /js/AppState.js: -------------------------------------------------------------------------------- 1 | export const AppState = (function() { 2 | return { 3 | showAbout: false 4 | } 5 | })(); -------------------------------------------------------------------------------- /js/Feature.js: -------------------------------------------------------------------------------- 1 | import { generateUID } from "./Helpers.js"; 2 | 3 | // List of user controlled features that should be activated by default 4 | const defaultFeatures = ['liga', 'clig', 'calt']; 5 | 6 | export function Feature(tag, name) { 7 | const id = generateUID(); 8 | let selected = defaultFeatures.includes(tag) ? true : false; 9 | let fontIds = []; 10 | 11 | return { 12 | id, 13 | tag, 14 | name, 15 | selected, 16 | fontIds 17 | } 18 | } -------------------------------------------------------------------------------- /js/Filter.js: -------------------------------------------------------------------------------- 1 | import { generateUID } from './Helpers.js'; 2 | 3 | export const Filter = function(name, label, apply) { 4 | const id = generateUID(); 5 | return { 6 | id, 7 | name, 8 | label, 9 | apply 10 | } 11 | } -------------------------------------------------------------------------------- /js/Filters.js: -------------------------------------------------------------------------------- 1 | import { Filter } from './Filter.js'; 2 | 3 | export const Filters = [ 4 | Filter('lowercase', 'Lowercase', (str) => str.toLowerCase()), 5 | Filter('uppercase', 'Uppercase', (str) => str.toUpperCase()), 6 | Filter('capitalised', 'Capitalised', (str) => str[0].toUpperCase() + str.slice(1)) 7 | ]; 8 | 9 | -------------------------------------------------------------------------------- /js/Font.js: -------------------------------------------------------------------------------- 1 | import { Words } from "./Words.js"; 2 | import { WordGenerator } from "./wordgenerator/WordGenerator.js"; 3 | import { Layout } from "./Layout.js"; 4 | import { generateUID, Computed } from "./Helpers.js"; 5 | 6 | export const Font = function(name, data, info) { 7 | const id = generateUID(); 8 | const fontFaceName = info.fileName; 9 | const features = []; 10 | const fontFeatureSettings = Computed(() => generateFontFeatureSettings(features)); 11 | const displayFeatureSettings = Computed(() => fontFeatureSettings.val); 12 | const wordGenerator = WordGenerator(fontFaceName, data); 13 | let isLoading = true; 14 | 15 | async function load() { 16 | const fontFace = new FontFace(fontFaceName, data); 17 | document.fonts.add(fontFace); 18 | 19 | await fontFace.load(); 20 | 21 | update(); 22 | } 23 | 24 | async function update() { 25 | isLoading = true; 26 | const words = await Words.get(); 27 | fontFeatureSettings.update(); 28 | 29 | try { 30 | await wordGenerator.sort(words, fontFeatureSettings.val); 31 | } catch (error) { 32 | console.log(error); 33 | } 34 | 35 | displayFeatureSettings.update(); 36 | 37 | // Dispatch event 38 | const event = new CustomEvent("font-loaded", {detail: {font}}); 39 | window.dispatchEvent(event); 40 | 41 | isLoading = false; 42 | } 43 | 44 | const font = { 45 | name, 46 | fontFaceName, 47 | data, 48 | info, 49 | features, 50 | fontFeatureSettings: displayFeatureSettings, 51 | id, 52 | load, 53 | update, 54 | wordGenerator, 55 | get isLoading() { 56 | return isLoading; 57 | }, 58 | } 59 | 60 | return font; 61 | } 62 | 63 | function generateFontFeatureSettings(features) { 64 | let str = ""; 65 | let featureStrings = []; 66 | 67 | for (let feature of features) { 68 | if (feature.selected) { 69 | featureStrings.push(`"${feature.tag}" on`); 70 | } else { 71 | featureStrings.push(`"${feature.tag}" off`); 72 | } 73 | } 74 | 75 | str = featureStrings.join(','); 76 | return str; 77 | } -------------------------------------------------------------------------------- /js/Fonts.js: -------------------------------------------------------------------------------- 1 | import { parse, getFontInfo } from './miniotparser/MiniOTParser.js'; 2 | import { Font } from './Font.js'; 3 | import { Feature } from './Feature.js'; 4 | import { generateUID } from './Helpers.js'; 5 | 6 | export const Fonts = (function() { 7 | const list = []; 8 | 9 | function add(font) { 10 | // Group the font by family name 11 | let family = list.find(family => family.name === font.info.familyName); 12 | 13 | // If the family group doesn't already exist, create it 14 | if (!family) { 15 | family = { 16 | id: generateUID(font.info.familyName), 17 | name: font.info.familyName, 18 | list: [], 19 | features: font.info.features.map(feature => Feature(feature.tag, feature.name)) 20 | }; 21 | list.push(family); 22 | } 23 | 24 | // Add the features that were not already present in the list 25 | // We allow for duplicate features only if they have different names 26 | // This allow for multiple version of the same stylistic sets inside a family 27 | font.info.features.forEach(featureInfo => { 28 | let feature = family.features.find(_feature => _feature.name === featureInfo.name); 29 | 30 | if (!feature) { 31 | feature = Feature(featureInfo.tag, featureInfo.name); 32 | family.features.push(feature); 33 | } 34 | // We also add a reference to the feature in the font object 35 | font.features.push(feature); 36 | }); 37 | 38 | // Push the font in the family list 39 | family.list.push(font); 40 | 41 | // Sort by style 42 | sortFonts(family.list); 43 | 44 | // Start sorting the dictionnary 45 | font.load(); 46 | 47 | // Dispatch event 48 | const event = new CustomEvent("font-added", {detail: {font: font}}); 49 | window.dispatchEvent(event); 50 | } 51 | 52 | function find(fontId) { 53 | for (let family of list) { 54 | for (let font of family.list) { 55 | if (font.id === fontId) { 56 | return font; 57 | } 58 | } 59 | } 60 | } 61 | 62 | // Update every font in the list 63 | function updateAll() { 64 | list.forEach(family => { 65 | family.list.forEach(font => font.update()); 66 | }); 67 | } 68 | 69 | // Get form data from the Features menu and activate/desactivate 70 | // the corresponding features in the list. 71 | // Then, update the fonts that includes one the updated features. 72 | function updateFeatures(formData) { 73 | for (let family of list) { 74 | if (formData.has(family.id)) { 75 | const updatedFeatures = []; 76 | 77 | const selectedFeatures = formData.getAll(family.id); 78 | for (let feature of family.features) { 79 | if (selectedFeatures.includes(feature.id) && !feature.selected) { 80 | // The feature has been activated 81 | feature.selected = true; 82 | updatedFeatures.push(feature); 83 | 84 | } else if (!selectedFeatures.includes(feature.id) && feature.selected) { 85 | // The feature has been desactivated 86 | feature.selected = false; 87 | updatedFeatures.push(feature); 88 | 89 | } 90 | } 91 | 92 | for (let font of family.list) { 93 | let needsUpdate = false; 94 | for (let feature of updatedFeatures) { 95 | if (font.features.includes(feature)) { 96 | needsUpdate = true; 97 | } 98 | } 99 | if (needsUpdate) font.update(); 100 | } 101 | } 102 | } 103 | } 104 | 105 | return { 106 | list, 107 | add, 108 | find, 109 | updateAll, 110 | updateFeatures, 111 | get length() { 112 | return list.reduce((acc, curr) => acc + curr.list.length, 0); 113 | } 114 | } 115 | 116 | })(); 117 | 118 | export function handleFontFiles(files) { 119 | const acceptedExtensions = /^.*\.(ttf|otf|woff|woff2)$/i; 120 | 121 | files = Array.from(files); 122 | const validFiles = files.filter(file => file.name.match(acceptedExtensions)); 123 | 124 | const loadedFonts = validFiles.map(loadFontFile); 125 | 126 | Promise.all(loadedFonts).then(fonts => { 127 | sortFonts(fonts); 128 | 129 | fonts.forEach(Fonts.add); 130 | }); 131 | } 132 | 133 | export function loadFontFile(file) { 134 | 135 | return new Promise((resolve, reject) => { 136 | // Removes file extension from name 137 | let fileName = file.name.substring(0, file.name.lastIndexOf('.')); 138 | // Replace any non alpha numeric characters with - 139 | fileName = fileName.replace(/\W+/g, "-"); 140 | // Remove leading digits in the filename 141 | fileName = fileName.replace(/^[0-9]+/g, ''); 142 | 143 | const reader = new FileReader(); 144 | 145 | reader.onloadend = function(e) { 146 | const fontInfo = getFontInfo(parse(e.target.result), fileName); 147 | // Check if a font with the same name does not exists already 148 | if (Fonts.find(font => font.name === fontInfo.fullName)) { 149 | reject(); 150 | } 151 | 152 | resolve(Font(fontInfo.fullName, e.target.result, fontInfo)); 153 | } 154 | 155 | reader.readAsArrayBuffer(file); 156 | }); 157 | } 158 | 159 | export function sortFonts(list) { 160 | if (list.length <= 1) return list; 161 | 162 | const sortedFonts = list; 163 | 164 | // Sort regular/italic pairs 165 | sortedFonts.sort((fontA, fontB) => { 166 | if (fontA.info.isItalic && !fontB.info.isItalic) { 167 | return 1 168 | } else if (!fontA.info.isItalic && fontB.info.isItalic) { 169 | return -1 170 | } else { 171 | return 0; 172 | } 173 | }); 174 | 175 | // Sort by weight 176 | sortedFonts.sort((fontA, fontB) => { 177 | return fontA.info.weightClass - fontB.info.weightClass; 178 | }); 179 | 180 | // Sort by width 181 | sortedFonts.sort((fontA, fontB) => { 182 | return fontA.info.widthClass - fontB.info.widthClass; 183 | }); 184 | 185 | // Sort by family name 186 | sortedFonts.sort((fontA, fontB) => { 187 | return fontA.info.familyName.localeCompare(fontB.info.familyName); 188 | }); 189 | 190 | return sortedFonts; 191 | } -------------------------------------------------------------------------------- /js/Helpers.js: -------------------------------------------------------------------------------- 1 | export function generateUID(str) { 2 | if (str && str.length) { 3 | return generateUIDFromString(str); 4 | } 5 | 6 | let firstPart = (Math.random() * 46656) | 0; 7 | let secondPart = (Math.random() * 46656) | 0; 8 | firstPart = ("000" + firstPart.toString(36)).slice(-3); 9 | secondPart = ("000" + secondPart.toString(36)).slice(-3); 10 | return firstPart + secondPart; 11 | } 12 | 13 | 14 | // Generate hash code from string 15 | export function generateUIDFromString(str) { 16 | var hash = 0, 17 | i, chr; 18 | if (str.length === 0) return hash; 19 | for (i = 0; i < str.length; i++) { 20 | chr = str.charCodeAt(i); 21 | hash = ((hash << 5) - hash) + chr; 22 | hash |= 0; // Convert to 32bit integer 23 | } 24 | return hash; 25 | } 26 | 27 | 28 | export function Box(val) { 29 | let _val = val; 30 | let callbacks = []; 31 | 32 | return { 33 | get val() { 34 | return _val; 35 | }, 36 | set val(newVal) { 37 | _val = newVal; 38 | callbacks.forEach(callback => callback()); 39 | }, 40 | onchange(callback) { 41 | callbacks.push(callback); 42 | } 43 | } 44 | } 45 | 46 | export function Computed(fn) { 47 | let _val = fn(); 48 | let callbacks = []; 49 | return { 50 | get val() { 51 | return _val; 52 | }, 53 | update() { 54 | const newVal = fn(); 55 | if (_val !== newVal) { 56 | _val = newVal; 57 | callbacks.forEach(callback => callback()); 58 | } 59 | }, 60 | onchange(callback) { 61 | callbacks.push(callback); 62 | }, 63 | dependsOn(...dependencies) { 64 | dependencies.forEach(dependency => { 65 | if (typeof dependency.onchange === 'function') { 66 | dependency.onchange(this.update); 67 | } 68 | }); 69 | } 70 | } 71 | } -------------------------------------------------------------------------------- /js/Layout.js: -------------------------------------------------------------------------------- 1 | import { Line } from "./Line.js"; 2 | import { Size } from "./Size.js"; 3 | import { Filters } from "./Filters.js"; 4 | import { Box } from "./Helpers.js"; 5 | 6 | export const defaultWidth = Size('15cm'); 7 | export const defaultSize = Size('60pt'); 8 | export const defaultFilter = Filters[2]; 9 | 10 | export const Layout = (function() { 11 | let lines = []; 12 | let width = Size(defaultWidth.get()); 13 | let size = Size(defaultSize.get()); 14 | let sizeLocked = Box(true); 15 | let filter = Box(defaultFilter); 16 | let filterLocked = Box(true); 17 | let font = Box(null); 18 | let fontLocked = Box(false); 19 | 20 | window.addEventListener('font-added', (e) => { 21 | // If there's was no font before, select the one that's been added 22 | if (font.val == null) { 23 | font.val = e.detail.font; 24 | } 25 | addLine(e.detail.font, defaultSize, defaultFilter); 26 | m.redraw(); 27 | }); 28 | 29 | function copyText() { 30 | navigator.clipboard.writeText(lines.map(line => line.text.val).join('\n')); 31 | } 32 | 33 | async function update() { 34 | lines.forEach(line => {line.update()}); 35 | } 36 | 37 | function addLine(_font, _size, _filter) { 38 | if (!_font && !_size && !_filter) { 39 | if (lines.length) { 40 | const lastLine = lines[lines.length-1]; 41 | _font = lastLine.font.val; 42 | _size = lastLine.size; 43 | _filter = lastLine.filter.val; 44 | } else { 45 | _font = font.val; 46 | _size = defaultSize; 47 | _filter = defaultFilter; 48 | } 49 | } 50 | lines.push(Line(_font, _size, _filter)); 51 | } 52 | 53 | function moveLine(line, to) { 54 | const from = lines.indexOf(line); 55 | if (from === -1 || to === from) return; 56 | 57 | const target = lines[from]; 58 | const increment = to < from ? -1 : 1; 59 | 60 | for(let k = from; k != to; k += increment){ 61 | lines[k] = lines[k + increment]; 62 | } 63 | lines[to] = target; 64 | } 65 | 66 | function getLine(id) { 67 | return lines.find(line => line.id === id) || null; 68 | } 69 | 70 | function indexOf(id) { 71 | return lines.indexOf(getLine(id)); 72 | } 73 | 74 | function removeLine(id) { 75 | if (id === undefined) { 76 | lines.pop(); 77 | } else { 78 | const index = lines.indexOf(lines.find(line => line.id === id)); 79 | lines.splice(index, 1); 80 | } 81 | } 82 | 83 | function clear() { 84 | lines.length = 0; 85 | } 86 | 87 | function textAlreadyUsed(str) { 88 | return lines.find(line => line.text.val === str) ? true : false; 89 | } 90 | 91 | return { 92 | lines, 93 | width, 94 | size, 95 | filter, 96 | font, 97 | sizeLocked, 98 | filterLocked, 99 | fontLocked, 100 | addLine, 101 | removeLine, 102 | getLine, 103 | moveLine, 104 | indexOf, 105 | update, 106 | copyText, 107 | clear, 108 | textAlreadyUsed, 109 | 110 | } 111 | })(); -------------------------------------------------------------------------------- /js/Line.js: -------------------------------------------------------------------------------- 1 | import { Size } from './Size.js'; 2 | import { Layout } from './Layout.js'; 3 | import { generateUID, Box, Computed } from './Helpers.js'; 4 | 5 | export function Line(_font, _size, _filter) { 6 | const id = generateUID(); 7 | let font = Box(_font); 8 | let size = Size(_size.get()); 9 | let filter = Box(_filter); 10 | 11 | const outputFont = Computed(() => Layout.fontLocked.val ? Layout.font.val : font.val); 12 | outputFont.dependsOn(Layout.font, Layout.fontLocked, font); 13 | 14 | const outputSize = Computed(() => Layout.sizeLocked.val ? Layout.size.getIn('px') : size.getIn('px')); 15 | outputSize.dependsOn(Layout.sizeLocked, Layout.size, size); 16 | 17 | const outputFilter = Computed(() => Layout.filterLocked.val ? Layout.filter.val : filter.val); 18 | outputFilter.dependsOn(Layout.filter, Layout.filterLocked, filter); 19 | 20 | const text = Computed(() => { 21 | const textOptions = outputFont.val.wordGenerator.getWords(outputSize.val, Layout.width.getIn('px'), outputFilter.val, Layout.lines.length); 22 | return textOptions.find(option => !Layout.textAlreadyUsed(option)) || ""; 23 | }); 24 | text.dependsOn(Layout.width, outputFont, outputSize, outputFilter); 25 | 26 | window.addEventListener('font-loaded', (e) => { 27 | if (e.detail.font === outputFont.val) { 28 | text.update(); 29 | m.redraw(); 30 | } 31 | }); 32 | 33 | function remove() { 34 | Layout.removeLine(id); 35 | } 36 | 37 | function copyText() { 38 | navigator.clipboard.writeText(text.val); 39 | } 40 | 41 | return { 42 | id, 43 | font, 44 | size, 45 | filter, 46 | outputFont, 47 | outputSize, 48 | outputFilter, 49 | text, 50 | update: text.update, 51 | remove, 52 | copyText 53 | } 54 | } -------------------------------------------------------------------------------- /js/Size.js: -------------------------------------------------------------------------------- 1 | export const Size = function(_str) { 2 | let value, unit; 3 | let callbacks = []; 4 | 5 | ({value, unit} = processStr(_str)); 6 | 7 | function processStr(str) { 8 | if (typeof str === 'string' && str !== ""){ 9 | str = str.replace(',', '.'); 10 | var split = str.match(/^([-.\d]+(?:\.\d+)?)(.*)$/); 11 | return {'value': parseFloat(split[1].trim()), 'unit': split[2].trim() || unit}; 12 | } 13 | else { 14 | return { 'value': value, 'unit': unit }; 15 | } 16 | } 17 | 18 | function increment() { 19 | value += 1; 20 | onchange(); 21 | } 22 | 23 | function decrement() { 24 | value -= 1; 25 | onchange(); 26 | } 27 | 28 | function set(str) { 29 | ({value, unit} = processStr(str)); 30 | onchange(); 31 | } 32 | 33 | function get() { 34 | return `${parseFloat(value.toFixed(2))}${unit}`; 35 | } 36 | 37 | function getIn(targetUnit) { 38 | return convert(value, unit).to(targetUnit); 39 | } 40 | 41 | function setIn(srcUnit, newValue) { 42 | value = convert(newValue, srcUnit).to(unit); 43 | onchange(); 44 | } 45 | 46 | function convert(value, unit) { 47 | const ratios = { 48 | 'cm': 37.8, 49 | 'mm': 3.78, 50 | 'in': 96, 51 | 'pt': 1.333, 52 | 'pc': 16, 53 | 'px': 1 54 | } 55 | 56 | const valueInPixel = value * ratios[unit]; 57 | 58 | return { 59 | to: function(targetUnit) { 60 | return valueInPixel / ratios[targetUnit]; 61 | } 62 | } 63 | } 64 | 65 | function onchange() { 66 | callbacks.forEach(callback => callback()); 67 | } 68 | 69 | return { 70 | get value() { 71 | return value; 72 | }, 73 | get unit() { 74 | return unit 75 | }, 76 | get, 77 | getIn, 78 | set, 79 | setIn, 80 | increment, 81 | decrement, 82 | onchange(callback) { 83 | callbacks.push(callback); 84 | } 85 | } 86 | } -------------------------------------------------------------------------------- /js/Words.js: -------------------------------------------------------------------------------- 1 | export const Words = (function() { 2 | const languages = [ 3 | { 4 | name: 'catalan', 5 | label: 'Catalan', 6 | code: 'ca', 7 | selected: false 8 | }, { 9 | name: 'czech', 10 | label: 'Czech', 11 | code: 'cs', 12 | selected: false 13 | }, { 14 | name: 'danish', 15 | label: 'Danish', 16 | code: 'da', 17 | selected: false 18 | }, { 19 | name: 'dutch', 20 | label: 'Dutch', 21 | code: 'nl', 22 | selected: false 23 | }, { 24 | name: 'english', 25 | label: 'English', 26 | code: 'en', 27 | selected: true 28 | }, { 29 | name: 'finnish', 30 | label: 'Finnish', 31 | code: 'fi', 32 | selected: false 33 | }, { 34 | name: 'french', 35 | label: 'French', 36 | code: 'fr', 37 | selected: false 38 | }, { 39 | name: 'german', 40 | label: 'German', 41 | code: 'de', 42 | selected: false 43 | }, { 44 | name: 'hungarian', 45 | label: 'Hungarian', 46 | code: 'hu', 47 | selected: false 48 | }, { 49 | name: 'icelandic', 50 | label: 'Icelandic', 51 | code: 'is', 52 | selected: false 53 | }, { 54 | name: 'italian', 55 | label: 'Italian', 56 | code: 'it', 57 | selected: false 58 | }, { 59 | name: 'latin', 60 | label: 'Latin', 61 | code: 'la', 62 | selected: false 63 | }, { 64 | name: 'norwegian', 65 | label: 'Norwegian', 66 | code: 'no', 67 | selected: false 68 | }, { 69 | name: 'polish', 70 | label: 'Polish', 71 | code: 'pl', 72 | selected: false 73 | }, { 74 | name: 'slovak', 75 | label: 'Slovak', 76 | code: 'sk', 77 | selected: false 78 | }, { 79 | name: 'spanish', 80 | label: 'Spanish', 81 | code: 'es', 82 | selected: false 83 | } 84 | ]; 85 | 86 | const sources = [ 87 | { 88 | name: 'dictionary', 89 | label: 'Dictionary', 90 | selected: true, 91 | words: (function() { 92 | const obj = {}; 93 | languages.forEach(language => { 94 | obj[language.name] = { 95 | url: `words/dictionaries/${language.name}.json`, 96 | list: null 97 | } 98 | }); 99 | return obj; 100 | })() 101 | }, { 102 | name: 'wikipedia', 103 | label: 'Wikipedia article titles', 104 | selected: false, 105 | words: (function() { 106 | const obj = {}; 107 | languages.forEach(language => { 108 | obj[language.name] = { 109 | url: `words/wikipedia/${language.code}_wikipedia.json`, 110 | list: null 111 | } 112 | }); 113 | return obj; 114 | })() 115 | } 116 | ] 117 | 118 | function loadFile(url) { 119 | return fetch(url) 120 | .then(response => response.json()) 121 | .then(data => data.words) 122 | .catch(error => console.error('Error loading JSON file:', error)); 123 | } 124 | 125 | async function get() { 126 | let words = []; 127 | const promises = []; 128 | for (const source of sources.filter(source => source.selected)) { 129 | for (const language of languages.filter(lang => lang.selected)) { 130 | const listObject = source.words[language.name]; 131 | if (listObject.list === null) { 132 | listObject.list = loadFile(listObject.url); 133 | } 134 | promises.push(listObject.list); 135 | listObject.list.then(list => { 136 | words = words.concat(list); 137 | }); 138 | 139 | } 140 | } 141 | await Promise.all(promises); 142 | return words; 143 | } 144 | 145 | 146 | return { 147 | get, 148 | data: { 149 | languages, 150 | sources 151 | } 152 | }; 153 | 154 | })(); -------------------------------------------------------------------------------- /js/app.js: -------------------------------------------------------------------------------- 1 | import { Fonts } from "./Fonts.js"; 2 | import { Header } from "./components/Header.js"; 3 | import { Footer } from "./components/Footer.js"; 4 | import { About } from "./components/About.js"; 5 | import { DropZone } from "./components/DropZone.js"; 6 | import { Specimen } from "./components/Specimen.js"; 7 | import { SplashScreen } from "./components/SplashScreen.js"; 8 | 9 | const root = document.querySelector('#app'); 10 | 11 | const App = { 12 | view: function(vnode) { 13 | return [ 14 | m(Header), 15 | m(DropZone), 16 | m('main.main', 17 | Fonts.length ? m(Specimen) : m(SplashScreen), 18 | m(About) 19 | ), 20 | m(Footer) 21 | ] 22 | } 23 | } 24 | 25 | m.mount(root, App); -------------------------------------------------------------------------------- /js/components/About.js: -------------------------------------------------------------------------------- 1 | import { AppState } from "../AppState.js"; 2 | import { SVGAnimation } from "./SVGAnimation.js"; 3 | 4 | export function About(initialVnode) { 5 | return { 6 | view: function(vnode) { 7 | return m('section.about', {class: AppState.showAbout ? 'open' : ''}, 8 | m(SVGAnimation, {src: 'svg/stack-and-justify-animation.svg', frames: 75}), 9 | m('div.about-text', 10 | m('p.t-big', 11 | m('em.bold', "Stack & Justify"), 12 | m('span', " is a tool to help create type specimens by finding words or phrases of the same width. It is free to use and distributed under GPLv3 license.") 13 | ), 14 | m('p.t-big', "Font files are not uploaded, they remain stored locally in your browser."), 15 | m('p.t-big', 16 | m('span', "For a similar tool, also check "), 17 | m('a.big-link', {target: '_blank', href: "https://workshop.mass-driver.com/waterfall"}, "Mass Driver’s Waterfall"), 18 | m('span', " from which this tool was inspired.") 19 | ), 20 | m('p.col-1', 21 | m('span', "If you want to support this project, you can make a donation via Paypal"), 22 | m('br'), 23 | m('a.t-big.donation-btn', {target: '_blank', href: "https://www.paypal.com/donate/?hosted_button_id=677KMWSBSRL3N"}, "Donate") 24 | ), 25 | ), 26 | ) 27 | } 28 | } 29 | } -------------------------------------------------------------------------------- /js/components/CopyButton.js: -------------------------------------------------------------------------------- 1 | import { Tooltip } from './Tooltip.js'; 2 | 3 | export function CopyButton(initialVnode) { 4 | return { 5 | oncreate: function(vnode) { 6 | vnode.dom.querySelector('button').addEventListener('click', vnode.attrs.onclick); 7 | }, 8 | view: function(vnode) { 9 | return m('div.copy-button', 10 | m('button', '📋'), 11 | m(Tooltip, {label: 'Copy line to clipboard', activeLabel: 'Copied'}) 12 | ) 13 | } 14 | } 15 | } -------------------------------------------------------------------------------- /js/components/CopyButtonGlobal.js: -------------------------------------------------------------------------------- 1 | import { Tooltip } from './Tooltip.js'; 2 | 3 | export function CopyButtonGlobal(initialVnode) { 4 | return { 5 | oncreate: function(vnode) { 6 | vnode.dom.querySelector('button').addEventListener('click', vnode.attrs.onclick); 7 | }, 8 | view: function(vnode) { 9 | return m('div.copy-button', 10 | m('button', '📋'), 11 | m(Tooltip, {label: 'Copy all lines to clipboard', activeLabel: 'Copied'}) 12 | ) 13 | } 14 | } 15 | } -------------------------------------------------------------------------------- /js/components/DarkModeButton.js: -------------------------------------------------------------------------------- 1 | export function DarkModeButton(initialVnode) { 2 | return { 3 | view: function(vnode) { 4 | return m('button.dark-mode-btn', { 5 | onclick: () => { document.body.classList.toggle('dark') } 6 | }, "◒") 7 | } 8 | } 9 | } -------------------------------------------------------------------------------- /js/components/DeleteButton.js: -------------------------------------------------------------------------------- 1 | import { Tooltip } from './Tooltip.js'; 2 | 3 | export function DeleteButton(initialVnode) { 4 | return { 5 | view: function(vnode) { 6 | return m('div.update-button', 7 | m('button', {onclick: vnode.attrs.onclick }, '🗑'), 8 | m(Tooltip, {label: 'Delete line'}) 9 | ) 10 | } 11 | } 12 | } -------------------------------------------------------------------------------- /js/components/DeleteButtonGlobal.js: -------------------------------------------------------------------------------- 1 | import { Tooltip } from './Tooltip.js'; 2 | 3 | export function DeleteButtonGlobal(initialVnode) { 4 | return { 5 | view: function(vnode) { 6 | return m('div.update-button', 7 | m('button', {onclick: vnode.attrs.onclick },'🗑'), 8 | m(Tooltip, {label: 'Clear all lines'}) 9 | ) 10 | } 11 | } 12 | } -------------------------------------------------------------------------------- /js/components/DropZone.js: -------------------------------------------------------------------------------- 1 | import { Fonts, handleFontFiles } from "../Fonts.js"; 2 | import { Font } from "../Font.js"; 3 | 4 | export function DropZone(initialVnode) { 5 | return { 6 | oncreate: function(vnode) { 7 | let lastTarget = null; 8 | 9 | window.addEventListener('dragover', function(e) { 10 | e.preventDefault(); 11 | e.dataTransfer.dropEffect = "copy"; 12 | }); 13 | 14 | window.addEventListener('dragenter', function(e) { 15 | lastTarget = e.target; 16 | e.dataTransfer.effectAllowed = "copy"; 17 | vnode.dom.classList.add('active'); 18 | }); 19 | 20 | window.addEventListener('dragleave', function(e) { 21 | if(e.target === lastTarget || e.target === document) { 22 | vnode.dom.classList.remove('active'); 23 | } 24 | }); 25 | 26 | vnode.dom.addEventListener('drop', function(e) { 27 | e.preventDefault(); 28 | 29 | let files = e.dataTransfer.files; 30 | 31 | handleFontFiles(files); 32 | 33 | vnode.dom.classList.remove('active'); 34 | }); 35 | }, 36 | view: function(vnode) { 37 | return m('div.drop-zone') 38 | } 39 | } 40 | } -------------------------------------------------------------------------------- /js/components/FeaturesMenu.js: -------------------------------------------------------------------------------- 1 | import { Layout } from "../Layout.js"; 2 | import { Fonts } from "../Fonts.js"; 3 | import { generateUID } from "../Helpers.js"; 4 | 5 | export function FeaturesMenu(initialVnode) { 6 | let open = false; 7 | let needsUpdate = false; 8 | 9 | function update(e) { 10 | e.preventDefault(); 11 | 12 | const formData = new FormData(e.target); 13 | Fonts.updateFeatures(formData); 14 | 15 | open = false; 16 | needsUpdate = false; 17 | 18 | m.redraw(); 19 | } 20 | 21 | return { 22 | oncreate: function(vnode) { 23 | // Close the menu when the user clicks anywhere else 24 | document.addEventListener('click', (e) => { 25 | const menu = vnode.dom.querySelector('.menu'); 26 | const btn = vnode.dom.querySelector('.menu-button'); 27 | 28 | if (!menu.contains(e.target) && e.target !== btn && open) { 29 | open = false; 30 | m.redraw(); 31 | } 32 | }); 33 | }, 34 | view: function(vnode) { 35 | const familiesWithFeatures = Fonts.list.filter(family => family.features.length); 36 | const disabled = familiesWithFeatures.length === 0; 37 | 38 | return m('div.menu-container.features', 39 | m('button.menu-button', { 40 | class: disabled ? "disabled" : "", 41 | disabled, 42 | onclick: () => { open = !open } 43 | }, "Features ▿"), 44 | m('form.menu', {onsubmit: update, style: {visibility: open ? 'visible' : 'hidden'}}, 45 | familiesWithFeatures.map(family => { 46 | return m(FeaturesSubmenu, { 47 | key: family.id, 48 | family, 49 | open: true, 50 | onchange: () => { needsUpdate = true }, 51 | }); 52 | }), 53 | m('div.menu-update', {class: !needsUpdate ? "disabled" : ""}, 54 | m('button.bold', {type: 'submit', disabled: !needsUpdate},'↻ Update') 55 | ) 56 | ) 57 | ) 58 | } 59 | } 60 | } 61 | 62 | export function FeaturesSubmenu(initialVnode) { 63 | let open = initialVnode.attrs.open; 64 | let offsetHeight = null; 65 | let update = function() {}; 66 | 67 | return { 68 | oncreate: function(vnode) { 69 | offsetHeight = vnode.dom.offsetHeight; 70 | }, 71 | onupdate: function(vnode) { 72 | vnode.dom.querySelector('.submenu-content').style.maxHeight = open ? `${offsetHeight}px` : '0'; 73 | }, 74 | view: function(vnode) { 75 | const family = vnode.attrs.family; 76 | return m('fieldset.submenu', 77 | m('legend.submenu-header.submenu-toggle', {onclick: () => { open = !open }, class: open ? "open" : "closed"}, 78 | m('span', family.name), 79 | m('span.submenu-toggle', "▿") 80 | ), 81 | m('div.submenu-content', 82 | family.features.map(feature => { 83 | return m(FeatureCheckbox, { 84 | key: feature.id, 85 | family, 86 | feature, 87 | onchange: vnode.attrs.onchange 88 | }); 89 | }) 90 | ) 91 | ); 92 | } 93 | } 94 | } 95 | 96 | function FeatureCheckbox(initialVnode) { 97 | let checked = initialVnode.attrs.feature.selected; 98 | 99 | return { 100 | view: function(vnode) { 101 | const family = vnode.attrs.family; 102 | const feature = vnode.attrs.feature; 103 | return m('div.checkbox', 104 | m('input', {name: family.id, 105 | type: 'checkbox', 106 | id: `${feature.tag}-${feature.id}`, 107 | value: feature.id, 108 | checked: checked, 109 | onchange: (e) => {checked = e.currentTarget.checked; vnode.attrs.onchange()}, 110 | } 111 | ), 112 | m('label', {for: `${feature.tag}-${feature.id}`}, 113 | m('span.feature-tag', feature.tag), 114 | m('span', feature.name) 115 | ) 116 | ) 117 | } 118 | } 119 | } -------------------------------------------------------------------------------- /js/components/FilterSelect.js: -------------------------------------------------------------------------------- 1 | import { Layout } from "../Layout.js"; 2 | import { Filters } from "../Filters.js"; 3 | 4 | export function FilterSelect(initialVnode) { 5 | return { 6 | view: function(vnode) { 7 | const line = vnode.attrs.params; 8 | return m('div.select-wrapper', {class: Layout.filterLocked.val ? "disabled": ""}, 9 | m('select.case-select', { 10 | disabled: Layout.filterLocked.val, 11 | onchange: (e) => {line.filter.val = Filters.find(filter => filter.id === e.currentTarget.value)}, 12 | }, 13 | Filters.map(filter => { 14 | return m('option', {value: filter.id, selected: line.filter.val.id === filter.id}, filter.label) 15 | }) 16 | ) 17 | ) 18 | } 19 | } 20 | } -------------------------------------------------------------------------------- /js/components/FilterSelectGlobal.js: -------------------------------------------------------------------------------- 1 | import { Layout } from "../Layout.js"; 2 | import { Filters } from "../Filters.js"; 3 | import { Tooltip } from './Tooltip.js'; 4 | 5 | export function FilterSelectGlobal(initialVnode) { 6 | return { 7 | view: function(vnode) { 8 | return m('div.case-select', 9 | m('div.case-select-lock', 10 | m('button', { 11 | onclick: () => {Layout.filterLocked.val = !Layout.filterLocked.val} 12 | }, Layout.filterLocked.val ? '🔒' : '🔓'), 13 | m(Tooltip, {label: 'Apply to all lines'}) 14 | ), 15 | m('div.select-wrapper', {class: !Layout.filterLocked.val ? "disabled" : ""}, 16 | m('select.case-select', { 17 | disabled: !Layout.filterLocked.val, 18 | onchange: (e) => {Layout.filter.val = Filters.find(filter => filter.id === e.currentTarget.value)}, 19 | }, 20 | Filters.map(filter => { 21 | return m('option', {value: filter.id, selected: Layout.filter.val.id === filter.id}, filter.label) 22 | }) 23 | ) 24 | ) 25 | ) 26 | } 27 | } 28 | } -------------------------------------------------------------------------------- /js/components/FontInput.js: -------------------------------------------------------------------------------- 1 | import { Fonts, handleFontFiles } from "../Fonts.js"; 2 | import { Font } from "../Font.js"; 3 | 4 | export function FontInput(initialVnode) { 5 | return { 6 | oncreate: function(vnode) { 7 | const input = vnode.dom.querySelector('input[type="file"'); 8 | vnode.dom.querySelector('a').addEventListener('click', (e) => { 9 | e.preventDefault(); 10 | input.click(); 11 | }); 12 | 13 | input.addEventListener('change', (e) => { 14 | let files = input.files; 15 | 16 | handleFontFiles(files); 17 | }); 18 | }, 19 | view: function(vnode) { 20 | return m('form.drop-message', 21 | m('input', {type: 'file', id:'file-upload', multiple:'multiple', accept: 'font/otf, font/ttf, font/woff, font/woff2, .otf, .ttf, .woff, .woff2', style:{display: 'none'}}), 22 | m('span', 'Drop your fonts anywhere or '), 23 | m('label', {for: 'file-upload'}, 24 | m('a.drop-btn', 'browse your computer ↗') 25 | ) 26 | ) 27 | } 28 | } 29 | } -------------------------------------------------------------------------------- /js/components/FontSelect.js: -------------------------------------------------------------------------------- 1 | import { Layout } from "../Layout.js"; 2 | import { Fonts } from "../Fonts.js"; 3 | 4 | export function FontSelect(initialVnode) { 5 | return { 6 | oncreate: function(vnode) { 7 | // Get the width of the hidden label and update the width of the select 8 | const width = vnode.dom.querySelector('.font-select-hidden-label').offsetWidth; 9 | vnode.dom.querySelector('select').style.width = width + 'px'; 10 | }, 11 | onupdate: function(vnode) { 12 | // Get the width of the hidden label and update the width of the select 13 | const width = vnode.dom.querySelector('.font-select-hidden-label').offsetWidth; 14 | vnode.dom.querySelector('select').style.width = width + 'px'; 15 | }, 16 | view: function(vnode) { 17 | const line = vnode.attrs.params; 18 | return m('div.font-select', 19 | m('span.font-select-hidden-label', {style: {position: 'absolute', visibility: 'hidden'}}, line.font.val.name), 20 | m('div.select-wrapper', {class: Layout.fontLocked.val ? "disabled" : ""}, 21 | m('select', { 22 | disabled: Layout.fontLocked.val, 23 | oninput: (e) => {line.font.val = Fonts.find(e.target.selectedOptions[0].value)}, 24 | }, 25 | Fonts.list.map(family => { 26 | return m('optgroup', {label: family.name}, 27 | family.list.map(font => { 28 | return m('option', { value: font.id, selected: line.font.val.id == font.id}, font.name); 29 | }) 30 | ) 31 | }) 32 | ) 33 | ) 34 | ) 35 | } 36 | } 37 | } -------------------------------------------------------------------------------- /js/components/FontSelectGlobal.js: -------------------------------------------------------------------------------- 1 | import { Layout } from "../Layout.js"; 2 | import { Fonts } from "../Fonts.js"; 3 | import { Tooltip } from "./Tooltip.js"; 4 | 5 | export function FontSelectGlobal(initialVnode) { 6 | return { 7 | onupdate: function(vnode) { 8 | // Get the width of the hidden label and update the width of the select 9 | const width = vnode.dom.querySelector('.font-select-hidden-label').offsetWidth; 10 | vnode.dom.querySelector('select').style.width = width + 'px'; 11 | }, 12 | view: function(vnode) { 13 | const fontName = Layout.font.val ? Layout.font.val.name : ''; 14 | return m('div.font-select', 15 | m('span.font-select-hidden-label', {style: {position: 'absolute', visibility: 'hidden'}}, fontName), 16 | m('div.select-wrapper', {class: !Layout.fontLocked.val ? "disabled" : ""}, 17 | m('select', { 18 | disabled: !Layout.fontLocked.val, 19 | oninput: (e) => {Layout.font.val = Fonts.find(e.target.selectedOptions[0].value)}, 20 | }, 21 | Fonts.list.map(family => { 22 | return m('optgroup', {label: family.name}, 23 | family.list.map(font => { 24 | return m('option', { value: font.id, selected: Layout.font.val.id == font.id}, font.name); 25 | }) 26 | ) 27 | }) 28 | ), 29 | ), 30 | m('div.font-select-lock', 31 | m('button', { 32 | onclick: () => {Layout.fontLocked.val = !Layout.fontLocked.val} 33 | }, Layout.fontLocked.val ? '🔒' : '🔓'), 34 | m(Tooltip, {label: 'Apply to all lines'}) 35 | ) 36 | ) 37 | } 38 | } 39 | } -------------------------------------------------------------------------------- /js/components/Footer.js: -------------------------------------------------------------------------------- 1 | export function Footer(initialVnode) { 2 | return { 3 | view: function(vnode) { 4 | return m('footer.footer', 5 | m('div.credit', 6 | m('span', 'Created by '), 7 | m('a', {target: "_blank", href: "https://max-esnee.com"}, 'Max Esnée ↗') 8 | ), 9 | m('div.github', 10 | m('span', 'Source code available on '), 11 | m('a', {target: "_blank", href: "https://github.com/maxesnee/stack-and-justify"}, 'Github ↗') 12 | ) 13 | ) 14 | } 15 | } 16 | } -------------------------------------------------------------------------------- /js/components/Header.js: -------------------------------------------------------------------------------- 1 | import { AppState } from "../AppState.js"; 2 | import { SVG } from "./SVG.js"; 3 | import { FontInput } from "./FontInput.js"; 4 | import { DarkModeButton } from "./DarkModeButton.js"; 5 | import { OptionsMenu} from "./OptionsMenu.js"; 6 | import { FeaturesMenu } from "./FeaturesMenu.js"; 7 | 8 | export function Header(initialVnode) { 9 | return { 10 | view: function(vnode) { 11 | return m('header.header', 12 | m('h1.logo', 13 | m(SVG, {src: 'svg/logo.svg'}), 14 | m('span', 'Stack & Justify') 15 | ), 16 | m(FontInput), 17 | m(OptionsMenu), 18 | m(FeaturesMenu), 19 | m('div.header-btns', 20 | m(DarkModeButton), 21 | m('button.about-btn', {onclick: () => AppState.showAbout = !AppState.showAbout }, AppState.showAbout ? "❎" : "❓"), 22 | ) 23 | ) 24 | } 25 | } 26 | } -------------------------------------------------------------------------------- /js/components/IconSpinning.js: -------------------------------------------------------------------------------- 1 | export function IconSpinning(initialVnode) { 2 | return { 3 | view: function(vnode) { 4 | return m('svg.icon-spinning', {xmlns:'http://www.w3.org/2000/svg', viewBox:'0 0 1357 1358', width: 9.5, height: 9.5, fill:'currentColor'}, 5 | m('path', {d: 'M677 215c60 0 109-49 109-107C786 48 737 0 677 0c-58 0-109 49-107 108 2 58 49 107 107 107Zm-403 976c60 0 109-50 107-107-2-61-47-107-107-107-58 0-107 48-107 107 0 58 49 107 107 107ZM107 786c59 0 108-47 108-107 0-58-49-107-108-107C47 572 0 621 0 679c0 60 47 107 107 107Zm572 572c58 0 107-49 107-108 0-58-49-107-107-107-60 0-107 49-107 107 0 59 48 108 107 108ZM274 382c57 0 107-47 107-107 0-59-47-108-107-107-59 1-108 48-107 107s49 107 107 107Zm809 808c57 0 107-43 107-107 0-60-48-107-107-107-61 0-107 47-107 107s46 107 107 107Zm167-404c59 0 107-48 107-107v-1c0-59-48-110-107-108-60 2-107 49-107 109 0 59 49 107 107 107Zm-168-404c60 0 107-47 107-107 0-61-47-106-107-107-59-1-107 46-107 107 0 59 49 107 107 107Z'}) 6 | ) 7 | } 8 | } 9 | } -------------------------------------------------------------------------------- /js/components/Line.js: -------------------------------------------------------------------------------- 1 | import { SizeInput } from "./SizeInput.js"; 2 | import { FontSelect } from "./FontSelect.js"; 3 | import { FilterSelect } from "./FilterSelect.js"; 4 | import { CopyButton } from "./CopyButton.js"; 5 | import { UpdateButton } from "./UpdateButton.js"; 6 | import { DeleteButton } from "./DeleteButton.js"; 7 | import { Layout } from "../Layout.js"; 8 | 9 | export function Line(initialVnode) { 10 | return { 11 | view: function(vnode) { 12 | let line = vnode.attrs.line; 13 | return m('div', {class: 'specimen-line', id: line.id}, 14 | m('div.line-left-col', 15 | m(SizeInput, {params: line}), 16 | m(FontSelect, {params: line}) 17 | ), 18 | m('div.line-middle-col', 19 | m('div.text', { 20 | class: !line.outputFont.val.isLoading ? 'visible' : 'hidden', 21 | style: { 22 | whiteSpace: "nowrap", 23 | fontSize: line.outputSize.val+'px', 24 | width: Layout.width.get(), 25 | fontFamily: line.outputFont.val.fontFaceName, 26 | height: (line.outputSize.val * 1.2)+'px', // Get the line height 27 | fontFeatureSettings: line.outputFont.val.fontFeatureSettings.val 28 | }, }, line.text.val), 29 | !line.outputFont.val.isLoading && line.text.val === '' ? m('div.no-words-found', 'No words found ☹') : '', 30 | m('div.loading', {class: line.outputFont.val.isLoading ? 'visible' : 'hidden'}, 31 | m('span', "Loading"), 32 | m('div.icon-spinning', "◌") 33 | ) 34 | ), 35 | m('div.line-right-col', 36 | m(FilterSelect, {params: line}), 37 | m(CopyButton, {onclick: line.copyText}), 38 | m(UpdateButton, {onclick: line.update}), 39 | m(DeleteButton, {onclick: line.remove}) 40 | ) 41 | ); 42 | } 43 | } 44 | } -------------------------------------------------------------------------------- /js/components/NewLineButton.js: -------------------------------------------------------------------------------- 1 | import { Layout } from '../Layout.js'; 2 | 3 | export function NewLineButton(initialVnode) { 4 | return { 5 | view: function(vnode) { 6 | return m('div.new-line', {onclick: () => Layout.addLine()}, 7 | m('button.new-line-button', '⊕ Add new line') 8 | ) 9 | } 10 | } 11 | } -------------------------------------------------------------------------------- /js/components/OptionsMenu.js: -------------------------------------------------------------------------------- 1 | import { Words } from "../Words.js"; 2 | import { Layout } from "../Layout.js"; 3 | import { Fonts } from "../Fonts.js"; 4 | 5 | export function OptionsMenu(initialVnode) { 6 | // Menu status 7 | let open = false; 8 | 9 | let needsUpdate = false; 10 | 11 | // Temporarily holds the selected options before they are applies 12 | let options = { 13 | languages: Object.fromEntries(Words.data.languages.map(lang => [lang.name, lang.selected])), 14 | sources: Object.fromEntries(Words.data.sources.map(source => [source.name, source.selected])) 15 | }; 16 | 17 | async function update(e) { 18 | e.preventDefault(); 19 | 20 | // Update the selection of languages and dictionaries 21 | for (const language of Words.data.languages) { 22 | language.selected = options.languages[language.name]; 23 | } 24 | 25 | for (const source of Words.data.sources) { 26 | source.selected = options.sources[source.name]; 27 | } 28 | 29 | Fonts.updateAll(); 30 | 31 | open = false; 32 | needsUpdate = false; 33 | } 34 | 35 | return { 36 | oncreate: function(vnode) { 37 | // Close the menu when the user clicks anywhere else 38 | document.addEventListener('click', (e) => { 39 | const menu = vnode.dom.querySelector('.menu'); 40 | const btn = vnode.dom.querySelector('.menu-button'); 41 | 42 | if (!menu.contains(e.target) && e.target !== btn && open) { 43 | open = false; 44 | m.redraw(); 45 | } 46 | }); 47 | }, 48 | view: function(vnode) { 49 | return m('div.menu-container.options', 50 | m('button.menu-button', {onclick: () => { open = !open }}, "Options ▿"), 51 | m('form.menu', {style: {visibility: open ? 'visible' : 'hidden'}}, 52 | m('fieldset.submenu', 53 | m('legend.submenu-header', 'Languages'), 54 | m('div.submenu-content', 55 | Words.data.languages.map(lang => { 56 | return m('div.checkbox', 57 | m('input', { 58 | name: 'languages', 59 | type: 'checkbox', 60 | id:lang.name, 61 | value: lang.name, 62 | checked: options.languages[lang.name], 63 | onclick: (e) => { options.languages[lang.name] = e.currentTarget.checked; needsUpdate = true; } 64 | }), 65 | m('label', {for: lang.name}, lang.label) 66 | ) 67 | }) 68 | ) 69 | ), 70 | m('fieldset.submenu', 71 | m('legend.submenu-header', 'Sources'), 72 | m('div.submenu-content', 73 | Words.data.sources.map(source => { 74 | return m('div.checkbox', 75 | m('input', { 76 | name: 'sources', 77 | type: 'checkbox', 78 | id:source.name, 79 | value: source.name, 80 | checked: options.sources[source.name], 81 | onclick: (e) => { options.sources[source.name] = e.currentTarget.checked; needsUpdate = true; } 82 | }), 83 | m('label', {for: source.name}, source.label) 84 | ) 85 | }) 86 | ) 87 | ), 88 | m('div.menu-update', {class: !needsUpdate ? "disabled" : ""}, 89 | m('button.bold', {disabled: !needsUpdate, onclick: update},'↻ Update') 90 | ) 91 | ) 92 | ) 93 | } 94 | } 95 | } -------------------------------------------------------------------------------- /js/components/SVG.js: -------------------------------------------------------------------------------- 1 | export function SVG(initialVnode) { 2 | return { 3 | oninit: function(vnode) { 4 | fetch(vnode.attrs.src) 5 | .then(response => response.text()) 6 | .then(svgStr =>{ 7 | const parser = new DOMParser(); 8 | const svg = parser.parseFromString(svgStr, 'image/svg+xml').childNodes[0]; 9 | vnode.dom.replaceWith(svg); 10 | }) 11 | 12 | }, 13 | view: function(vnode) { 14 | return m('div.svg'); 15 | } 16 | } 17 | } -------------------------------------------------------------------------------- /js/components/SVGAnimation.js: -------------------------------------------------------------------------------- 1 | export function SVGAnimation(initialVnode) { 2 | function animate(svg, frames) { 3 | const fps = 12; 4 | let start; 5 | let lastFrameCount = 0; 6 | requestAnimationFrame(step); 7 | 8 | function step(timestamp) { 9 | if (start === undefined) start = timestamp; 10 | const elapsed = timestamp - start; 11 | const frameCount = Math.floor(elapsed / (1000/fps))%frames; 12 | if (frameCount !== lastFrameCount) { 13 | svg.children[lastFrameCount].setAttribute('display', 'none'); 14 | svg.children[frameCount].setAttribute('display', 'block'); 15 | } 16 | 17 | lastFrameCount = frameCount; 18 | requestAnimationFrame(step); 19 | } 20 | } 21 | 22 | return { 23 | oninit: function(vnode) { 24 | fetch(vnode.attrs.src) 25 | .then(response => response.text()) 26 | .then(svgStr => { 27 | const parser = new DOMParser(); 28 | const svg = parser.parseFromString(svgStr, 'image/svg+xml').childNodes[0]; 29 | vnode.dom.replaceWith(svg); 30 | animate(svg, vnode.attrs.frames); 31 | }) 32 | }, 33 | view: function(vnode) { 34 | return m('div.svg-animation'); 35 | } 36 | } 37 | } -------------------------------------------------------------------------------- /js/components/SizeInput.js: -------------------------------------------------------------------------------- 1 | import { Layout } from "../Layout.js"; 2 | 3 | export function SizeInput(initialVnode) { 4 | let isFocused = false; 5 | let editValue = ""; 6 | 7 | function onfocus(e) { 8 | isFocused = true; 9 | editValue = e.target.value; 10 | } 11 | 12 | function oninput(e) { 13 | if (isFocused) { 14 | editValue = e.target.value; 15 | } 16 | } 17 | 18 | function onblur(e) { 19 | isFocused = false; 20 | } 21 | 22 | return { 23 | view: function(vnode) { 24 | return m('div.size-input', {class: Layout.sizeLocked.val ? "disabled" : ""}, 25 | m('button', { 26 | onclick: () => { vnode.attrs.params.size.decrement() }, 27 | disabled: Layout.sizeLocked.val 28 | }, '-'), 29 | m('input', { 30 | type: 'text', 31 | value: isFocused ? editValue : vnode.attrs.params.size.get(), 32 | onchange: (e) => {vnode.attrs.params.size.set(e.currentTarget.value)}, 33 | disabled: Layout.sizeLocked.val, 34 | oninput, 35 | onfocus, 36 | onblur 37 | }), 38 | m('button', { 39 | onclick: () => { vnode.attrs.params.size.increment() }, 40 | disabled: Layout.sizeLocked.val 41 | }, '+') 42 | ) 43 | } 44 | } 45 | } -------------------------------------------------------------------------------- /js/components/SizeInputGlobal.js: -------------------------------------------------------------------------------- 1 | import { Layout } from "../Layout.js"; 2 | import { Tooltip } from "./Tooltip.js"; 3 | 4 | export function SizeInputGlobal(initialVnode) { 5 | let isFocused = false; 6 | let editValue = ""; 7 | 8 | function onfocus(e) { 9 | isFocused = true; 10 | editValue = e.target.value; 11 | } 12 | 13 | function oninput(e) { 14 | if (isFocused) { 15 | editValue = e.target.value; 16 | } 17 | } 18 | 19 | function onblur(e) { 20 | isFocused = false; 21 | } 22 | 23 | return { 24 | view: function(vnode) { 25 | return m('div.size-input.size-input-global', 26 | m('div.size-input-wrapper', {class: !Layout.sizeLocked.val ? "disabled" : ""}, 27 | m('button', { 28 | onclick: () => { Layout.size.decrement() }, 29 | disabled: !Layout.sizeLocked.val 30 | }, '-'), 31 | m('input', { 32 | type: 'text', 33 | value: isFocused ? editValue : Layout.size.get(), 34 | onchange: (e) => {Layout.size.set(e.currentTarget.value)}, 35 | disabled: !Layout.sizeLocked.val, 36 | oninput, 37 | onfocus, 38 | onblur 39 | }), 40 | m('button', { 41 | onclick: () => { Layout.size.increment() }, 42 | disabled: !Layout.sizeLocked.val 43 | }, '+') 44 | ), 45 | m('div.size-input-lock', 46 | m('button', { 47 | onclick: () => {Layout.sizeLocked.val = !Layout.sizeLocked.val} 48 | }, `${Layout.sizeLocked.val ? '🔒' : '🔓'}`), 49 | m(Tooltip, {label: 'Apply to all lines'}) 50 | ) 51 | ) 52 | } 53 | } 54 | } -------------------------------------------------------------------------------- /js/components/Specimen.js: -------------------------------------------------------------------------------- 1 | import { AppState } from "../AppState.js"; 2 | import { Layout } from "../Layout.js"; 3 | import { Fonts } from "../Fonts.js"; 4 | import { Line } from "./Line.js"; 5 | import { SizeInputGlobal } from "./SizeInputGlobal.js"; 6 | import { FontSelectGlobal } from "./FontSelectGlobal.js"; 7 | import { WidthInput } from "./WidthInput.js"; 8 | import { FilterSelectGlobal } from "./FilterSelectGlobal.js"; 9 | import { CopyButtonGlobal } from "./CopyButtonGlobal.js"; 10 | import { UpdateButtonGlobal } from "./UpdateButtonGlobal.js"; 11 | import { NewLineButton } from "./NewLineButton.js"; 12 | import { DeleteButtonGlobal } from "./DeleteButtonGlobal.js"; 13 | 14 | 15 | export function Specimen(initialVnode) { 16 | let isDragging = false; 17 | let draggedEl = null; 18 | let draggedClone = null; 19 | let dragStartPosX; 20 | let dragStartPosY; 21 | 22 | function onmousedown(e) { 23 | const target = e.target; 24 | 25 | // Don't prevent the text itself from being selected 26 | if (target.classList.contains('text')) return; 27 | 28 | // Only target the line itself or the immediate children 29 | // to prevent from triggering dragging when interacting with inputs 30 | if (!target.classList.contains('specimen-line') && 31 | !target.parentElement.classList.contains('specimen-line')) { 32 | return; 33 | } 34 | 35 | // Mark the line being dragged 36 | draggedEl = target.closest('.specimen-line'); 37 | 38 | // Prevent triggering text selection while dragging 39 | draggedEl.parentElement.style.userSelect = 'none'; 40 | draggedEl.parentElement.style.webkitUserSelect = 'none'; 41 | 42 | // Deselect all text 43 | window.getSelection().removeAllRanges() 44 | 45 | // Create a clone of the line and display it 46 | draggedClone = createClone(draggedEl); 47 | draggedEl.insertAdjacentElement('beforebegin', draggedClone); 48 | 49 | // Hide the line being dragged 50 | draggedEl.classList.add('dragged'); 51 | 52 | // Get the mouse position 53 | dragStartPosX = e.clientX; 54 | dragStartPosY = e.clientY; 55 | 56 | isDragging = true; 57 | 58 | // console.log(`started dragging: ${target.className}`); 59 | } 60 | 61 | function onmousemove(e) { 62 | if (!isDragging) return; 63 | 64 | const dragOffsetX = e.clientX - dragStartPosX; 65 | const dragOffsetY = e.clientY - dragStartPosY; 66 | 67 | // The clone follows the mouse while dragging 68 | draggedClone.style.transform = `translate(${dragOffsetX}px, ${dragOffsetY}px)`; 69 | 70 | // Get the drop target 71 | const lineEls = document.querySelectorAll('.specimen-line'); 72 | lineEls.forEach(lineEl => { 73 | if (lineEl === draggedEl) return; 74 | 75 | const rect = lineEl.getBoundingClientRect(); 76 | if (e.clientY > rect.top && e.clientY <= rect.bottom) { 77 | // Get the line and move to the new index 78 | const line = Layout.getLine(draggedEl.id); 79 | const targetIndex = Layout.indexOf(lineEl.id); 80 | 81 | Layout.moveLine(line, targetIndex); 82 | m.redraw(); 83 | } 84 | }); 85 | 86 | // console.log('dragging'); 87 | } 88 | 89 | function onmouseup(e) { 90 | if (!isDragging) return; 91 | 92 | draggedEl.classList.remove('dragged'); 93 | draggedEl.parentElement.style.userSelect = ''; 94 | draggedEl.parentElement.style.webkitUserSelect = ''; 95 | draggedEl = null; 96 | 97 | draggedClone.remove(); 98 | draggedClone = null; 99 | 100 | isDragging = false; 101 | 102 | // console.log('stopped dragging'); 103 | } 104 | 105 | 106 | return { 107 | oncreate: function(vnode) { 108 | vnode.dom.querySelector('.specimen-body').addEventListener('mousedown', onmousedown); 109 | window.addEventListener('mousemove', onmousemove); 110 | window.addEventListener('mouseup', onmouseup); 111 | }, 112 | view: function(vnode) { 113 | return m('div', {class: 'specimen', style: {display: AppState.showAbout ? 'none' : ''}}, 114 | m('header.specimen-header', 115 | m('div.line-left-col', 116 | m(SizeInputGlobal), 117 | Fonts.length ? m(FontSelectGlobal) : '' 118 | ), 119 | m('div.line-middle-col', 120 | m(WidthInput) 121 | ), 122 | m('div.line-right-col', 123 | m(FilterSelectGlobal), 124 | m(CopyButtonGlobal, {onclick: Layout.copyText}), 125 | m(UpdateButtonGlobal, {onclick: Layout.update}), 126 | m(DeleteButtonGlobal, {onclick: Layout.clear}) 127 | ), 128 | ), 129 | m('div.specimen-body', 130 | Layout.lines.map((line) => m(Line, {line, key:line.id})), 131 | m(NewLineButton) 132 | ) 133 | ) 134 | } 135 | } 136 | } 137 | 138 | function createClone(target) { 139 | const rect = target.getBoundingClientRect(); 140 | const clone = target.cloneNode(true); 141 | const cloneChildEls = clone.children; 142 | const targetChildEls = target.children; 143 | const cloneSelectEls = clone.querySelectorAll('select'); 144 | const targetSelectEls = target.querySelectorAll('select'); 145 | 146 | target.style.position = "relative"; 147 | clone.classList.add('drag-clone'); 148 | 149 | // The clone is fixed, so it loses the grid of the target element 150 | // to keep it visually identical, we have to position everything absolutely 151 | clone.style.position = 'fixed'; 152 | clone.style.width = rect.width + 'px'; 153 | clone.style.height = rect.height + 'px'; 154 | clone.style.left = rect.left + 'px'; 155 | clone.style.top = rect.top + 'px'; 156 | 157 | for (let i = 0; i < cloneChildEls.length; i++) { 158 | cloneChildEls[i].style.position = 'absolute'; 159 | cloneChildEls[i].style.left = targetChildEls[i].offsetLeft + 'px'; 160 | cloneChildEls[i].style.width = targetChildEls[i].offsetWidth + 'px'; 161 | cloneChildEls[i].style.top = targetChildEls[i].offsetTop + 'px'; 162 | cloneChildEls[i].style.height = targetChildEls[i].offsetHeight + 'px'; 163 | } 164 | 165 | // The clone doesn't inherit the selected options 166 | for (let i = 0; i < cloneSelectEls.length; i++) { 167 | cloneSelectEls[i].value = targetSelectEls[i].value; 168 | } 169 | 170 | return clone; 171 | } -------------------------------------------------------------------------------- /js/components/SplashScreen.js: -------------------------------------------------------------------------------- 1 | import { SVGAnimation } from "./SVGAnimation.js"; 2 | 3 | export function SplashScreen(initialVnode) { 4 | return { 5 | view: function(vnode) { 6 | return m('div.splash-screen', 7 | m('div.splash-screen-text', 8 | m(SVGAnimation, {src: 'svg/font-files-animation.svg', frames: 18}), 9 | m('p.t-big', 'To start, drop one or more font files anywhere on the window.'), 10 | m('p.splash-screen-notice', 'Your font files are not uploaded, they remain stored locally in your browser.') 11 | ) 12 | ) 13 | } 14 | } 15 | } -------------------------------------------------------------------------------- /js/components/Tooltip.js: -------------------------------------------------------------------------------- 1 | export function Tooltip(initialVnode) { 2 | 3 | function onclick(vnode) { 4 | if (vnode.attrs.activeLabel) { 5 | vnode.dom.textContent = vnode.attrs.activeLabel; 6 | positionTooltip(vnode); 7 | } 8 | } 9 | 10 | function onmouseleave(vnode) { 11 | if (vnode.attrs.activeLabel) { 12 | vnode.dom.textContent = vnode.attrs.label; 13 | positionTooltip(vnode); 14 | } 15 | } 16 | 17 | function positionTooltip(vnode) { 18 | const rect = vnode.dom.getBoundingClientRect(); 19 | const parentRect = vnode.dom.parentElement.getBoundingClientRect(); 20 | const padding = parseFloat(getComputedStyle(document.body).getPropertyValue('padding-right'))/2; 21 | let pos = -rect.width/2 + parentRect.width/2; 22 | 23 | // if tooltip is outside the viewport 24 | if (rect.right + pos + padding > document.body.clientWidth) { 25 | pos -= (rect.right + pos + padding) - document.body.clientWidth; 26 | } 27 | 28 | vnode.dom.style.transform = `translateX(${pos}px)`; 29 | } 30 | 31 | return { 32 | oncreate: function(vnode) { 33 | positionTooltip(vnode); 34 | vnode.dom.parentElement.addEventListener('click', () => onclick(vnode)); 35 | vnode.dom.parentElement.addEventListener('mouseleave', () => onmouseleave(vnode)); 36 | }, 37 | view: function(vnode) { 38 | return m('div.tooltip', vnode.attrs.label); 39 | } 40 | } 41 | } -------------------------------------------------------------------------------- /js/components/UpdateButton.js: -------------------------------------------------------------------------------- 1 | import { Tooltip } from './Tooltip.js'; 2 | 3 | export function UpdateButton(initialVnode) { 4 | return { 5 | view: function(vnode) { 6 | return m('div.update-button', 7 | m('button', {onclick: vnode.attrs.onclick },'↻'), 8 | m(Tooltip, {label: 'Refresh line'}) 9 | ) 10 | } 11 | } 12 | } -------------------------------------------------------------------------------- /js/components/UpdateButtonGlobal.js: -------------------------------------------------------------------------------- 1 | import { Tooltip } from './Tooltip.js'; 2 | 3 | export function UpdateButtonGlobal(initialVnode) { 4 | return { 5 | view: function(vnode) { 6 | return m('div.update-button', 7 | m('button', {onclick: vnode.attrs.onclick },'↻'), 8 | m(Tooltip, {label: 'Refresh all lines'}) 9 | ) 10 | } 11 | } 12 | } -------------------------------------------------------------------------------- /js/components/WidthInput.js: -------------------------------------------------------------------------------- 1 | import { Layout } from "../Layout.js"; 2 | 3 | export function WidthInput(initialVnode) { 4 | let isDragging = false; 5 | let startFromRight = null; 6 | let startWidth = Layout.width.getIn('px'); 7 | let startX = 0; 8 | let dX = 0; 9 | let isFocused = false; 10 | let editValue = ""; 11 | 12 | document.body.onmousemove = onmousemove; 13 | document.body.onmouseup = onmouseup; 14 | 15 | function onmousedown(e) { 16 | startFromRight = e.currentTarget.classList.contains('right'); 17 | isDragging = true; 18 | startX = e.clientX; 19 | startWidth = Layout.width.getIn('px'); 20 | } 21 | 22 | function onmousemove(e) { 23 | if (isDragging) { 24 | dX = e.clientX - startX; 25 | 26 | // Invert delta if dragging started from left side 27 | dX = startFromRight ? dX : -dX; 28 | 29 | // Width is distributed on both side, so this allow 30 | // the resizing to be in sync with cursor visually 31 | dX = dX*2; 32 | 33 | // Prevent from getting negative width 34 | dX = dX > -startWidth ? dX : -startWidth; 35 | 36 | // Move the label if the input is too small 37 | const label = document.querySelector('.width-input input'); 38 | 39 | if (label.offsetWidth > startWidth + dX) { 40 | label.classList.add('small'); 41 | } else { 42 | label.classList.remove('small'); 43 | } 44 | 45 | Layout.width.setIn('px', startWidth + dX); 46 | m.redraw(); 47 | } 48 | } 49 | 50 | function onmouseup(e) { 51 | isDragging = false; 52 | } 53 | 54 | function onfocus(e) { 55 | isFocused = true; 56 | editValue = e.target.value; 57 | } 58 | 59 | function oninput(e) { 60 | if (isFocused) { 61 | editValue = e.target.value; 62 | } 63 | } 64 | 65 | function onblur(e) { 66 | isFocused = false; 67 | } 68 | 69 | return { 70 | view: function(vnode) { 71 | return m('div.width-input', {style: { width: Layout.width.get()}}, 72 | m('div.width-input-handle.left', {onmousedown}), 73 | m('span.width-input-line'), 74 | m('input', { 75 | type: 'text', 76 | value: isFocused ? editValue : Layout.width.get(), 77 | onchange: (e) => {Layout.width.set(e.currentTarget.value)}, 78 | oninput, 79 | onfocus, 80 | onblur 81 | }), 82 | m('span.width-input-line'), 83 | m('div.width-input-handle.right', {onmousedown}) 84 | ) 85 | } 86 | } 87 | } -------------------------------------------------------------------------------- /js/harfbuzzjs/hb.wasm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maxesnee/stack-and-justify/fb54fe697996d5d5592ad5d98e0d632213b3417d/js/harfbuzzjs/hb.wasm -------------------------------------------------------------------------------- /js/harfbuzzjs/hbjs.js: -------------------------------------------------------------------------------- 1 | export function hbjs(instance) { 2 | var exports = instance.exports; 3 | var heapu8 = new Uint8Array(exports.memory.buffer); 4 | var heapu32 = new Uint32Array(exports.memory.buffer); 5 | var heapi32 = new Int32Array(exports.memory.buffer); 6 | var heapf32 = new Float32Array(exports.memory.buffer); 7 | 8 | var HB_MEMORY_MODE_WRITABLE = 2; 9 | var HB_SET_VALUE_INVALID = -1; 10 | var HB_BUFFER_CONTENT_TYPE_GLYPHS = 2; 11 | var DONT_STOP = 0; 12 | var GSUB_PHASE = 1; 13 | var GPOS_PHASE = 2; 14 | 15 | function hb_tag(s) { 16 | return ( 17 | (s.charCodeAt(0) & 0xFF) << 24 | 18 | (s.charCodeAt(1) & 0xFF) << 16 | 19 | (s.charCodeAt(2) & 0xFF) << 8 | 20 | (s.charCodeAt(3) & 0xFF) << 0 21 | ); 22 | } 23 | 24 | var HB_BUFFER_SERIALIZE_FORMAT_JSON = hb_tag('JSON'); 25 | var HB_BUFFER_SERIALIZE_FLAG_NO_GLYPH_NAMES = 4; 26 | 27 | function _hb_untag(tag) { 28 | return [ 29 | String.fromCharCode((tag >> 24) & 0xFF), 30 | String.fromCharCode((tag >> 16) & 0xFF), 31 | String.fromCharCode((tag >> 8) & 0xFF), 32 | String.fromCharCode((tag >> 0) & 0xFF) 33 | ].join(''); 34 | } 35 | 36 | function _buffer_flag(s) { 37 | if (s == "BOT") { return 0x1; } 38 | if (s == "EOT") { return 0x2; } 39 | if (s == "PRESERVE_DEFAULT_IGNORABLES") { return 0x4; } 40 | if (s == "REMOVE_DEFAULT_IGNORABLES") { return 0x8; } 41 | if (s == "DO_NOT_INSERT_DOTTED_CIRCLE") { return 0x10; } 42 | if (s == "PRODUCE_UNSAFE_TO_CONCAT") { return 0x40; } 43 | return 0x0; 44 | } 45 | 46 | /** 47 | * Create an object representing a Harfbuzz blob. 48 | * @param {string} blob A blob of binary data (usually the contents of a font file). 49 | **/ 50 | function createBlob(blob) { 51 | var blobPtr = exports.malloc(blob.byteLength); 52 | heapu8.set(new Uint8Array(blob), blobPtr); 53 | var ptr = exports.hb_blob_create(blobPtr, blob.byteLength, HB_MEMORY_MODE_WRITABLE, blobPtr); 54 | return { 55 | ptr: ptr, 56 | /** 57 | * Free the object. 58 | */ 59 | destroy: function () { exports.hb_blob_destroy(ptr); } 60 | }; 61 | } 62 | 63 | /** 64 | * Return the typed array of HarfBuzz set contents. 65 | * @template {typeof Uint8Array | typeof Uint32Array | typeof Int32Array | typeof Float32Array} T 66 | * @param {number} setPtr Pointer of set 67 | * @param {T} arrayClass Typed array class 68 | * @returns {InstanceType} Typed array instance 69 | */ 70 | function typedArrayFromSet(setPtr, arrayClass) { 71 | let heap = heapu8; 72 | if (arrayClass === Uint32Array) { 73 | heap = heapu32; 74 | } else if (arrayClass === Int32Array) { 75 | heap = heapi32; 76 | } else if (arrayClass === Float32Array) { 77 | heap = heapf32; 78 | } 79 | const bytesPerElment = arrayClass.BYTES_PER_ELEMENT; 80 | const setCount = exports.hb_set_get_population(setPtr); 81 | const arrayPtr = exports.malloc( 82 | setCount * bytesPerElment, 83 | ); 84 | const arrayOffset = arrayPtr / bytesPerElment; 85 | const array = heap.subarray( 86 | arrayOffset, 87 | arrayOffset + setCount, 88 | ); 89 | heap.set(array, arrayOffset); 90 | exports.hb_set_next_many( 91 | setPtr, 92 | HB_SET_VALUE_INVALID, 93 | arrayPtr, 94 | setCount, 95 | ); 96 | return array; 97 | } 98 | 99 | /** 100 | * Create an object representing a Harfbuzz face. 101 | * @param {object} blob An object returned from `createBlob`. 102 | * @param {number} index The index of the font in the blob. (0 for most files, 103 | * or a 0-indexed font number if the `blob` came form a TTC/OTC file.) 104 | **/ 105 | function createFace(blob, index) { 106 | var ptr = exports.hb_face_create(blob.ptr, index); 107 | const upem = exports.hb_face_get_upem(ptr); 108 | return { 109 | ptr: ptr, 110 | upem, 111 | /** 112 | * Return the binary contents of an OpenType table. 113 | * @param {string} table Table name 114 | */ 115 | reference_table: function(table) { 116 | var blob = exports.hb_face_reference_table(ptr, hb_tag(table)); 117 | var length = exports.hb_blob_get_length(blob); 118 | if (!length) { return; } 119 | var blobptr = exports.hb_blob_get_data(blob, null); 120 | var table_string = heapu8.subarray(blobptr, blobptr+length); 121 | return table_string; 122 | }, 123 | /** 124 | * Return variation axis infos 125 | */ 126 | getAxisInfos: function() { 127 | var axis = exports.malloc(64 * 32); 128 | var c = exports.malloc(4); 129 | heapu32[c / 4] = 64; 130 | exports.hb_ot_var_get_axis_infos(ptr, 0, c, axis); 131 | var result = {}; 132 | Array.from({ length: heapu32[c / 4] }).forEach(function (_, i) { 133 | result[_hb_untag(heapu32[axis / 4 + i * 8 + 1])] = { 134 | min: heapf32[axis / 4 + i * 8 + 4], 135 | default: heapf32[axis / 4 + i * 8 + 5], 136 | max: heapf32[axis / 4 + i * 8 + 6] 137 | }; 138 | }); 139 | exports.free(c); 140 | exports.free(axis); 141 | return result; 142 | }, 143 | /** 144 | * Return unicodes the face supports 145 | */ 146 | collectUnicodes: function() { 147 | var unicodeSetPtr = exports.hb_set_create(); 148 | exports.hb_face_collect_unicodes(ptr, unicodeSetPtr); 149 | var result = typedArrayFromSet(unicodeSetPtr, Uint32Array); 150 | exports.hb_set_destroy(unicodeSetPtr); 151 | return result; 152 | }, 153 | /** 154 | * Free the object. 155 | */ 156 | destroy: function () { 157 | exports.hb_face_destroy(ptr); 158 | }, 159 | }; 160 | } 161 | 162 | var pathBuffer = ""; 163 | 164 | var nameBufferSize = 256; // should be enough for most glyphs 165 | var nameBuffer = exports.malloc(nameBufferSize); // permanently allocated 166 | 167 | /** 168 | * Create an object representing a Harfbuzz font. 169 | * @param {object} blob An object returned from `createFace`. 170 | **/ 171 | function createFont(face) { 172 | var ptr = exports.hb_font_create(face.ptr); 173 | var drawFuncsPtr = null; 174 | 175 | /** 176 | * Return a glyph as an SVG path string. 177 | * @param {number} glyphId ID of the requested glyph in the font. 178 | **/ 179 | function glyphToPath(glyphId) { 180 | if (!drawFuncsPtr) { 181 | var moveTo = function (dfuncs, draw_data, draw_state, to_x, to_y, user_data) { 182 | pathBuffer += `M${to_x},${to_y}`; 183 | } 184 | var lineTo = function (dfuncs, draw_data, draw_state, to_x, to_y, user_data) { 185 | pathBuffer += `L${to_x},${to_y}`; 186 | } 187 | var cubicTo = function (dfuncs, draw_data, draw_state, c1_x, c1_y, c2_x, c2_y, to_x, to_y, user_data) { 188 | pathBuffer += `C${c1_x},${c1_y} ${c2_x},${c2_y} ${to_x},${to_y}`; 189 | } 190 | var quadTo = function (dfuncs, draw_data, draw_state, c_x, c_y, to_x, to_y, user_data) { 191 | pathBuffer += `Q${c_x},${c_y} ${to_x},${to_y}`; 192 | } 193 | var closePath = function (dfuncs, draw_data, draw_state, user_data) { 194 | pathBuffer += 'Z'; 195 | } 196 | 197 | var moveToPtr = addFunction(moveTo, 'viiiffi'); 198 | var lineToPtr = addFunction(lineTo, 'viiiffi'); 199 | var cubicToPtr = addFunction(cubicTo, 'viiiffffffi'); 200 | var quadToPtr = addFunction(quadTo, 'viiiffffi'); 201 | var closePathPtr = addFunction(closePath, 'viiii'); 202 | drawFuncsPtr = exports.hb_draw_funcs_create(); 203 | exports.hb_draw_funcs_set_move_to_func(drawFuncsPtr, moveToPtr, 0, 0); 204 | exports.hb_draw_funcs_set_line_to_func(drawFuncsPtr, lineToPtr, 0, 0); 205 | exports.hb_draw_funcs_set_cubic_to_func(drawFuncsPtr, cubicToPtr, 0, 0); 206 | exports.hb_draw_funcs_set_quadratic_to_func(drawFuncsPtr, quadToPtr, 0, 0); 207 | exports.hb_draw_funcs_set_close_path_func(drawFuncsPtr, closePathPtr, 0, 0); 208 | } 209 | 210 | pathBuffer = ""; 211 | exports.hb_font_draw_glyph(ptr, glyphId, drawFuncsPtr, 0); 212 | return pathBuffer; 213 | } 214 | 215 | /** 216 | * Return glyph name. 217 | * @param {number} glyphId ID of the requested glyph in the font. 218 | **/ 219 | function glyphName(glyphId) { 220 | exports.hb_font_glyph_to_string( 221 | ptr, 222 | glyphId, 223 | nameBuffer, 224 | nameBufferSize 225 | ); 226 | var array = heapu8.subarray(nameBuffer, nameBuffer + nameBufferSize); 227 | return utf8Decoder.decode(array.slice(0, array.indexOf(0))); 228 | } 229 | 230 | return { 231 | ptr: ptr, 232 | glyphName: glyphName, 233 | glyphToPath: glyphToPath, 234 | /** 235 | * Return a glyph as a JSON path string 236 | * based on format described on https://svgwg.org/specs/paths/#InterfaceSVGPathSegment 237 | * @param {number} glyphId ID of the requested glyph in the font. 238 | **/ 239 | glyphToJson: function (glyphId) { 240 | var path = glyphToPath(glyphId); 241 | return path.replace(/([MLQCZ])/g, '|$1 ').split('|').filter(function (x) { return x.length; }).map(function (x) { 242 | var row = x.split(/[ ,]/g); 243 | return { type: row[0], values: row.slice(1).filter(function (x) { return x.length; }).map(function (x) { return +x; }) }; 244 | }); 245 | }, 246 | /** 247 | * Set the font's scale factor, affecting the position values returned from 248 | * shaping. 249 | * @param {number} xScale Units to scale in the X dimension. 250 | * @param {number} yScale Units to scale in the Y dimension. 251 | **/ 252 | setScale: function (xScale, yScale) { 253 | exports.hb_font_set_scale(ptr, xScale, yScale); 254 | }, 255 | /** 256 | * Set the font's variations. 257 | * @param {object} variations Dictionary of variations to set 258 | **/ 259 | setVariations: function (variations) { 260 | var entries = Object.entries(variations); 261 | var vars = exports.malloc(8 * entries.length); 262 | entries.forEach(function (entry, i) { 263 | heapu32[vars / 4 + i * 2 + 0] = hb_tag(entry[0]); 264 | heapf32[vars / 4 + i * 2 + 1] = entry[1]; 265 | }); 266 | exports.hb_font_set_variations(ptr, vars, entries.length); 267 | exports.free(vars); 268 | }, 269 | /** 270 | * Free the object. 271 | */ 272 | destroy: function () { exports.hb_font_destroy(ptr); } 273 | }; 274 | } 275 | 276 | /** 277 | * Use when you know the input range should be ASCII. 278 | * Faster than encoding to UTF-8 279 | **/ 280 | function createAsciiString(text) { 281 | var ptr = exports.malloc(text.length + 1); 282 | for (let i = 0; i < text.length; ++i) { 283 | const char = text.charCodeAt(i); 284 | if (char > 127) throw new Error('Expected ASCII text'); 285 | heapu8[ptr + i] = char; 286 | } 287 | heapu8[ptr + text.length] = 0; 288 | return { 289 | ptr: ptr, 290 | length: text.length, 291 | free: function () { exports.free(ptr); } 292 | }; 293 | } 294 | 295 | function createJsString(text) { 296 | const ptr = exports.malloc(text.length * 2); 297 | const words = new Uint16Array(exports.memory.buffer, ptr, text.length); 298 | for (let i = 0; i < words.length; ++i) words[i] = text.charCodeAt(i); 299 | return { 300 | ptr: ptr, 301 | length: words.length, 302 | free: function () { exports.free(ptr); } 303 | }; 304 | } 305 | 306 | /** 307 | * Create an object representing a Harfbuzz buffer. 308 | **/ 309 | function createBuffer() { 310 | var ptr = exports.hb_buffer_create(); 311 | return { 312 | ptr: ptr, 313 | /** 314 | * Add text to the buffer. 315 | * @param {string} text Text to be added to the buffer. 316 | **/ 317 | addText: function (text) { 318 | const str = createJsString(text); 319 | exports.hb_buffer_add_utf16(ptr, str.ptr, str.length, 0, str.length); 320 | str.free(); 321 | }, 322 | /** 323 | * Set buffer script, language and direction. 324 | * 325 | * This needs to be done before shaping. 326 | **/ 327 | guessSegmentProperties: function () { 328 | return exports.hb_buffer_guess_segment_properties(ptr); 329 | }, 330 | /** 331 | * Set buffer direction explicitly. 332 | * @param {string} direction: One of "ltr", "rtl", "ttb" or "btt" 333 | */ 334 | setDirection: function (dir) { 335 | exports.hb_buffer_set_direction(ptr, { 336 | ltr: 4, 337 | rtl: 5, 338 | ttb: 6, 339 | btt: 7 340 | }[dir] || 0); 341 | }, 342 | /** 343 | * Set buffer flags explicitly. 344 | * @param {string[]} flags: A list of strings which may be either: 345 | * "BOT" 346 | * "EOT" 347 | * "PRESERVE_DEFAULT_IGNORABLES" 348 | * "REMOVE_DEFAULT_IGNORABLES" 349 | * "DO_NOT_INSERT_DOTTED_CIRCLE" 350 | * "PRODUCE_UNSAFE_TO_CONCAT" 351 | */ 352 | setFlags: function (flags) { 353 | var flagValue = 0 354 | flags.forEach(function (s) { 355 | flagValue |= _buffer_flag(s); 356 | }) 357 | 358 | exports.hb_buffer_set_flags(ptr,flagValue); 359 | }, 360 | /** 361 | * Set buffer language explicitly. 362 | * @param {string} language: The buffer language 363 | */ 364 | setLanguage: function (language) { 365 | var str = createAsciiString(language); 366 | exports.hb_buffer_set_language(ptr, exports.hb_language_from_string(str.ptr,-1)); 367 | str.free(); 368 | }, 369 | /** 370 | * Set buffer script explicitly. 371 | * @param {string} script: The buffer script 372 | */ 373 | setScript: function (script) { 374 | var str = createAsciiString(script); 375 | exports.hb_buffer_set_script(ptr, exports.hb_script_from_string(str.ptr,-1)); 376 | str.free(); 377 | }, 378 | 379 | /** 380 | * Set the Harfbuzz clustering level. 381 | * 382 | * Affects the cluster values returned from shaping. 383 | * @param {number} level: Clustering level. See the Harfbuzz manual chapter 384 | * on Clusters. 385 | **/ 386 | setClusterLevel: function (level) { 387 | exports.hb_buffer_set_cluster_level(ptr, level) 388 | }, 389 | /** 390 | * Return the buffer contents as a JSON object. 391 | * 392 | * After shaping, this function will return an array of glyph information 393 | * objects. Each object will have the following attributes: 394 | * 395 | * - g: The glyph ID 396 | * - cl: The cluster ID 397 | * - ax: Advance width (width to advance after this glyph is painted) 398 | * - ay: Advance height (height to advance after this glyph is painted) 399 | * - dx: X displacement (adjustment in X dimension when painting this glyph) 400 | * - dy: Y displacement (adjustment in Y dimension when painting this glyph) 401 | * - flags: Glyph flags like `HB_GLYPH_FLAG_UNSAFE_TO_BREAK` (0x1) 402 | **/ 403 | json: function () { 404 | var length = exports.hb_buffer_get_length(ptr); 405 | var result = []; 406 | var infosPtr = exports.hb_buffer_get_glyph_infos(ptr, 0); 407 | var infosPtr32 = infosPtr / 4; 408 | var positionsPtr32 = exports.hb_buffer_get_glyph_positions(ptr, 0) / 4; 409 | var infos = heapu32.subarray(infosPtr32, infosPtr32 + 5 * length); 410 | var positions = heapi32.subarray(positionsPtr32, positionsPtr32 + 5 * length); 411 | for (var i = 0; i < length; ++i) { 412 | result.push({ 413 | g: infos[i * 5 + 0], 414 | cl: infos[i * 5 + 2], 415 | ax: positions[i * 5 + 0], 416 | ay: positions[i * 5 + 1], 417 | dx: positions[i * 5 + 2], 418 | dy: positions[i * 5 + 3], 419 | flags: exports.hb_glyph_info_get_glyph_flags(infosPtr + i * 20) 420 | }); 421 | } 422 | return result; 423 | }, 424 | /** 425 | * Free the object. 426 | */ 427 | destroy: function () { exports.hb_buffer_destroy(ptr); } 428 | }; 429 | } 430 | 431 | /** 432 | * Shape a buffer with a given font. 433 | * 434 | * This returns nothing, but modifies the buffer. 435 | * 436 | * @param {object} font: A font returned from `createFont` 437 | * @param {object} buffer: A buffer returned from `createBuffer` and suitably 438 | * prepared. 439 | * @param {object} features: A string of comma-separated OpenType features to apply. 440 | */ 441 | function shape(font, buffer, features) { 442 | var featuresPtr = 0; 443 | var featuresLen = 0; 444 | if (features) { 445 | features = features.split(","); 446 | featuresPtr = exports.malloc(16 * features.length); 447 | features.forEach(function (feature, i) { 448 | var str = createAsciiString(feature); 449 | if (exports.hb_feature_from_string(str.ptr, -1, featuresPtr + featuresLen * 16)) 450 | featuresLen++; 451 | str.free(); 452 | }); 453 | } 454 | 455 | exports.hb_shape(font.ptr, buffer.ptr, featuresPtr, featuresLen); 456 | if (featuresPtr) 457 | exports.free(featuresPtr); 458 | } 459 | 460 | /** 461 | * Shape a buffer with a given font, returning a JSON trace of the shaping process. 462 | * 463 | * This function supports "partial shaping", where the shaping process is 464 | * terminated after a given lookup ID is reached. If the user requests the function 465 | * to terminate shaping after an ID in the GSUB phase, GPOS table lookups will be 466 | * processed as normal. 467 | * 468 | * @param {object} font: A font returned from `createFont` 469 | * @param {object} buffer: A buffer returned from `createBuffer` and suitably 470 | * prepared. 471 | * @param {object} features: A string of comma-separated OpenType features to apply. 472 | * @param {number} stop_at: A lookup ID at which to terminate shaping. 473 | * @param {number} stop_phase: Either 0 (don't terminate shaping), 1 (`stop_at` 474 | refers to a lookup ID in the GSUB table), 2 (`stop_at` refers to a lookup 475 | ID in the GPOS table). 476 | */ 477 | function shapeWithTrace(font, buffer, features, stop_at, stop_phase) { 478 | var trace = []; 479 | var currentPhase = DONT_STOP; 480 | var stopping = false; 481 | var failure = false; 482 | 483 | var traceBufLen = 1024 * 1024; 484 | var traceBufPtr = exports.malloc(traceBufLen); 485 | 486 | var traceFunc = function (bufferPtr, fontPtr, messagePtr, user_data) { 487 | var message = utf8Decoder.decode(heapu8.subarray(messagePtr, heapu8.indexOf(0, messagePtr))); 488 | if (message.startsWith("start table GSUB")) 489 | currentPhase = GSUB_PHASE; 490 | else if (message.startsWith("start table GPOS")) 491 | currentPhase = GPOS_PHASE; 492 | 493 | if (currentPhase != stop_phase) 494 | stopping = false; 495 | 496 | if (failure) 497 | return 1; 498 | 499 | if (stop_phase != DONT_STOP && currentPhase == stop_phase && message.startsWith("end lookup " + stop_at)) 500 | stopping = true; 501 | 502 | if (stopping) 503 | return 0; 504 | 505 | exports.hb_buffer_serialize_glyphs( 506 | bufferPtr, 507 | 0, exports.hb_buffer_get_length(bufferPtr), 508 | traceBufPtr, traceBufLen, 0, 509 | fontPtr, 510 | HB_BUFFER_SERIALIZE_FORMAT_JSON, 511 | HB_BUFFER_SERIALIZE_FLAG_NO_GLYPH_NAMES); 512 | 513 | trace.push({ 514 | m: message, 515 | t: JSON.parse(utf8Decoder.decode(heapu8.subarray(traceBufPtr, heapu8.indexOf(0, traceBufPtr)))), 516 | glyphs: exports.hb_buffer_get_content_type(bufferPtr) == HB_BUFFER_CONTENT_TYPE_GLYPHS, 517 | }); 518 | 519 | return 1; 520 | } 521 | 522 | var traceFuncPtr = addFunction(traceFunc, 'iiiii'); 523 | exports.hb_buffer_set_message_func(buffer.ptr, traceFuncPtr, 0, 0); 524 | shape(font, buffer, features, 0); 525 | exports.free(traceBufPtr); 526 | 527 | return trace; 528 | } 529 | 530 | return { 531 | createBlob: createBlob, 532 | createFace: createFace, 533 | createFont: createFont, 534 | createBuffer: createBuffer, 535 | shape: shape, 536 | shapeWithTrace: shapeWithTrace 537 | }; 538 | }; -------------------------------------------------------------------------------- /js/miniotparser/MiniOTParser.js: -------------------------------------------------------------------------------- 1 | /* Mini OpenType Parser 2 | * 3 | * This is not a proper OpenType parser, please don't use it. 4 | * If you want an actual OpenType JS library, check opentype.js or Typr.js 5 | * 6 | * I made this because I only needed a handful of entries from the 'name', 'OS/2' 7 | * and 'GSUB' table and parsing the entire font seemed wasteful and was actually slow 8 | * with opentype.js. Typr.js is faster but doesn't support the 'GSUB' table. 9 | * 10 | */ 11 | 12 | const nameTableNames = [ 13 | 'copyright', 14 | 'fontFamily', 15 | 'fontSubfamily', 16 | 'uniqueID', 17 | 'fullName', 18 | 'version', 19 | 'postScriptName', 20 | 'trademark', 21 | 'manufacturer', 22 | 'designer', 23 | 'description', 24 | 'manufacturerURL', 25 | 'designerURL', 26 | 'license', 27 | 'licenseURL', 28 | 'reserved', 29 | 'typoFamily', 30 | 'typoSubfamily', 31 | 'compatibleFullName', 32 | 'sampleText', 33 | 'postScriptFindFontName', 34 | 'wwsFamily', 35 | 'wwsSubfamily' 36 | ]; 37 | 38 | // List of OpenType features that should have user control 39 | // Based on http://tiro.com/John/Enabling_Typography_(OTL).pdf 40 | export const userFeatures = { 41 | "smpl": "Simplified Forms", 42 | "trad": "Traditional Forms", 43 | "calt": "Contextual Alternates", 44 | "clig": "Contextual Ligatures", 45 | "jalt": "Justification Alternates", 46 | "liga": "Standard Ligatures", 47 | "rand": "Randomize", 48 | "afrc": "Alternative Fractions", 49 | "c2pc": "Petite Capitals From Capitals", 50 | "c2sc": "Small Capitals From Capitals", 51 | "case": "Case-Sensitive Forms", 52 | "cpsp": "Capital Spacing", 53 | "dlig": "Discretionary Ligatures", 54 | "dnom": "Denominators", 55 | "falt": "Final Glyph on Line Alternates", 56 | "frac": "Fractions", 57 | "halt": "Alternate Half Widths", 58 | "hist": "Historical Forms", 59 | "hwid": "Half Widths", 60 | "lnum": "Lining Figures", 61 | "mgrk": "Mathematical Greek", 62 | "nalt": "Alternate Annotation Forms", 63 | "numr": "Numerators", 64 | "onum": "Oldstyle Figures", 65 | "ordn": "Ordinals", 66 | "ornm": "Ornaments", 67 | "pcap": "Petite Capitals", 68 | "pnum": "Proportional Figures", 69 | "ruby": "Ruby Notation Forms", 70 | "salt": "Stylistic Alternates", 71 | "smcp": "Small Capitals", 72 | "ss01": "Stylistic Set 01", 73 | "ss02": "Stylistic Set 02", 74 | "ss03": "Stylistic Set 03", 75 | "ss04": "Stylistic Set 04", 76 | "ss05": "Stylistic Set 05", 77 | "ss06": "Stylistic Set 06", 78 | "ss07": "Stylistic Set 07", 79 | "ss08": "Stylistic Set 08", 80 | "ss09": "Stylistic Set 09", 81 | "ss10": "Stylistic Set 10", 82 | "ss11": "Stylistic Set 11", 83 | "ss12": "Stylistic Set 12", 84 | "ss13": "Stylistic Set 13", 85 | "ss14": "Stylistic Set 14", 86 | "ss15": "Stylistic Set 15", 87 | "ss16": "Stylistic Set 16", 88 | "ss17": "Stylistic Set 17", 89 | "ss18": "Stylistic Set 18", 90 | "ss19": "Stylistic Set 19", 91 | "ss20": "Stylistic Set 20", 92 | "subs": "Subscript", 93 | "sups": "Superscript", 94 | "swsh": "Swash", 95 | "titl": "Titling", 96 | "tnum": "Tabular Figures", 97 | "unic": "Unicase", 98 | "zero": "Slashed Zero", 99 | "lfbd": "Left Bounds", 100 | "rtbd": "Right Bounds", 101 | "kern": "Kerning" 102 | }; 103 | 104 | // Accepts a font file as an ArrayBuffer and return an objects containing parts 105 | // of the 'name', 'OS/2' and 'GSUB' tables. 106 | // The selection of fields is very specific to the need of Stack & Justify 107 | export function parse(buffer) { 108 | const font = {}; 109 | const data = new DataView(buffer, 0); 110 | const signature = getTag(data, 0); 111 | let numTables = getUint16(data, 4); 112 | let tableEntries = parseOpenTypeTableEntries(data, numTables); 113 | 114 | for (let tableEntry of tableEntries) { 115 | switch (tableEntry.tag) { 116 | case 'name': 117 | font.name = parseNameTable(data, tableEntry.offset); 118 | break; 119 | case 'OS/2': 120 | font.OS2 = parseOS2Table(data, tableEntry.offset); 121 | break; 122 | case 'GSUB': 123 | font.GSUB = parseGSUBTable(data, tableEntry.offset); 124 | break; 125 | } 126 | } 127 | 128 | return font; 129 | } 130 | 131 | // Format the font tables into a FontInfo object to use in the UI 132 | export function getFontInfo(font, fileName) { 133 | const info = {}; 134 | 135 | info.weightClass = font.OS2.usWeightClass; 136 | info.widthClass = font.OS2.usWidthClass; 137 | info.isItalic = font.OS2.fsSelection.italic; 138 | 139 | // Get proper names 140 | info.familyName = font.name.typoFamily || font.name.fontFamily; 141 | info.subfamilyName = font.name.typoSubfamily || font.name.fontSubfamily; 142 | info.fullName = info.familyName + ' ' + info.subfamilyName; 143 | info.fileName = fileName; 144 | 145 | // Create feature list 146 | info.features = []; 147 | if (font.GSUB === undefined) return info; 148 | 149 | for (const feature of font.GSUB.featureList) { 150 | const featureInfo = { 151 | tag: feature.tag, 152 | name: undefined 153 | } 154 | const featureAlreadyExists = info.features.some((_feature) => _feature.tag === feature.tag); 155 | 156 | if (userFeatures[feature.tag] && !featureAlreadyExists) { 157 | // Check if a custom description exists for the feature (usually for ss01-20) 158 | if (feature.uiNameID !== undefined) { 159 | featureInfo.name = font.name[feature.uiNameID]; 160 | } else { 161 | featureInfo.name = userFeatures[feature.tag]; 162 | } 163 | info.features.push(featureInfo); 164 | } 165 | } 166 | 167 | return info; 168 | } 169 | 170 | function parseOpenTypeTableEntries(data, numTables) { 171 | const tableEntries = []; 172 | let p = 12; 173 | for (let i = 0; i < numTables; i+=1) { 174 | const tag = getTag(data, p); 175 | const offset = getUint32(data, p+8); 176 | const length = getUint32(data, p+12); 177 | tableEntries.push({tag, offset, length, compression: false}); 178 | p += 16; 179 | } 180 | 181 | return tableEntries; 182 | } 183 | 184 | function parseNameTable(data, start) { 185 | const table = {}; 186 | const p = Parser(data, start); 187 | const format = p.parseUint16(); 188 | const recordsNum = p.parseUint16(); 189 | const storageOffset = start + p.parseUint16(); 190 | 191 | 192 | for (let i = 0; i < recordsNum; i++) { 193 | const platformID = p.parseUint16(); 194 | const encodingID = p.parseUint16(); 195 | const languageID = p.parseUint16(); 196 | const nameID = p.parseUint16(); 197 | const byteLength = p.parseUint16(); 198 | const recordOffset = storageOffset + p.parseUint16(); 199 | const name = nameTableNames[nameID] || nameID; 200 | 201 | if (platformID !== 3 || languageID !== 0x0409) continue; 202 | 203 | let text = getUTF16String(data, recordOffset, byteLength); 204 | table[name] = text; 205 | } 206 | 207 | return table; 208 | } 209 | 210 | function parseGSUBTable(data, start) { 211 | const table = {}; 212 | const featureListOffset = start + getUint16(data, start + 6); 213 | const featureCount = getUint16(data, featureListOffset); 214 | const p = Parser(data, featureListOffset + 2); 215 | 216 | table.featureList = []; 217 | for (let i = 0; i < featureCount; i++) { 218 | const featureTag = p.parseTag(); 219 | let uiNameID; 220 | 221 | // Some features can have custom descriptions 222 | // The uiNameID points to a string in the name table 223 | const featureOffset = featureListOffset + p.parseUint16(); 224 | const featureParamsOffset = getUint16(data, featureOffset); 225 | if (featureParamsOffset !== 0) { 226 | uiNameID = getUint16(data, featureOffset + featureParamsOffset + 2); 227 | } 228 | 229 | table.featureList.push({ 230 | tag: featureTag, 231 | uiNameID 232 | }); 233 | } 234 | 235 | return table; 236 | } 237 | 238 | function parseOS2Table(data, start) { 239 | const table = {}; 240 | table.usWeightClass = getUint16(data, start + 4); 241 | table.usWidthClass = getUint16(data, start + 6); 242 | const fsSelectionRaw = getUint16(data, start + 62); 243 | table.fsSelection = parseFsSelection(fsSelectionRaw); 244 | 245 | return table; 246 | } 247 | 248 | function parseFsSelection(uint16) { 249 | const flags = {} 250 | const bits = uint16.toString(2).padStart(16, '0').split('').reverse().map(bit => bit === "0" ? false : true); 251 | flags.italic = bits[0]; 252 | flags.underscore = bits[1]; 253 | flags.negative = bits[2]; 254 | flags.outlined = bits[3]; 255 | flags.strikeout = bits[4]; 256 | flags.bold = bits[5]; 257 | flags.regular = bits[6]; 258 | flags.useTypoMetrics = bits[7]; 259 | flags.wws = bits[8]; 260 | flags.oblique = bits[9]; 261 | 262 | return flags; 263 | } 264 | 265 | function Parser(data, offset) { 266 | let dOffset = 0; 267 | 268 | function parseUint16() { 269 | const val = getUint16(data, offset + dOffset); 270 | dOffset += 2; 271 | return val; 272 | } 273 | 274 | function parseInt16() { 275 | const val = getInt16(data, offset + dOffset); 276 | dOffset += 2; 277 | return val; 278 | } 279 | 280 | function parseTag() { 281 | const val = getTag(data, offset + dOffset); 282 | dOffset += 4; 283 | return val; 284 | } 285 | 286 | return { 287 | data, 288 | offset, 289 | get currentOffset() { 290 | return offset + dOffset; 291 | }, 292 | parseUint16, 293 | parseInt16, 294 | parseTag 295 | } 296 | } 297 | 298 | function getUTF16String(data, offset, numBytes) { 299 | const codePoints = []; 300 | const numChars = numBytes/2; 301 | for (let i = 0; i < numChars; i++, offset += 2) { 302 | codePoints[i] = data.getUint16(offset); 303 | } 304 | 305 | return String.fromCharCode.apply(null, codePoints); 306 | } 307 | 308 | function getUint8(data, offset) { 309 | return data.getUint8(offset, false); 310 | } 311 | 312 | function getUint16(data, offset) { 313 | return data.getUint16(offset, false); 314 | } 315 | 316 | function getInt16(data, offset) { 317 | return data.getInt16(offset, false); 318 | } 319 | 320 | function getUint32(data, offset) { 321 | return data.getUint32(offset, false); 322 | } 323 | 324 | function getTag(data, offset) { 325 | let tag = ''; 326 | for (let i = offset; i < offset + 4; i += 1) { 327 | tag += String.fromCharCode(data.getInt8(i)); 328 | } 329 | return tag; 330 | } 331 | 332 | function getBytes(data, start, numBytes) { 333 | const bytes = []; 334 | for (let i = start; i < start + numBytes; i += 1) { 335 | bytes.push(data.getUint8(i)); 336 | } 337 | 338 | return bytes; 339 | } -------------------------------------------------------------------------------- /js/vendor/mithril.min.js: -------------------------------------------------------------------------------- 1 | !function(){"use strict";function e(e,t,n,r,o,l){return{tag:e,key:t,attrs:n,children:r,text:o,dom:l,domSize:void 0,state:void 0,events:void 0,instance:void 0}}e.normalize=function(t){return Array.isArray(t)?e("[",void 0,void 0,e.normalizeChildren(t),void 0,void 0):null==t||"boolean"==typeof t?null:"object"==typeof t?t:e("#",void 0,void 0,String(t),void 0,void 0)},e.normalizeChildren=function(t){var n=[];if(t.length){for(var r=null!=t[0]&&null!=t[0].key,o=1;o0&&(i.className=l.join(" ")),o[e]={tag:n,attrs:i}}function a(e,t){var r=t.attrs,o=n.call(r,"class"),i=o?r.class:r.className;if(t.tag=e.tag,t.attrs={},!l(e.attrs)&&!l(r)){var a={};for(var u in r)n.call(r,u)&&(a[u]=r[u]);r=a}for(var u in e.attrs)n.call(e.attrs,u)&&"className"!==u&&!n.call(r,u)&&(r[u]=e.attrs[u]);for(var u in null==i&&null==e.attrs.className||(r.className=null!=i?null!=e.attrs.className?String(e.attrs.className)+" "+String(i):i:null!=e.attrs.className?e.attrs.className:null),o&&(r.class=null),r)if(n.call(r,u)&&"key"!==u){t.attrs=r;break}return t}function u(n){if(null==n||"string"!=typeof n&&"function"!=typeof n&&"function"!=typeof n.view)throw Error("The selector must be either a string or a component.");var r=t.apply(1,arguments);return"string"==typeof n&&(r.children=e.normalizeChildren(r.children),"["!==n)?a(o[n]||i(n),r):(r.tag=n,r)}u.trust=function(t){return null==t&&(t=""),e("<",void 0,void 0,t,void 0,void 0)},u.fragment=function(){var n=t.apply(0,arguments);return n.tag="[",n.children=e.normalizeChildren(n.children),n};var s=new WeakMap;var f={delayedRemoval:s,domFor:function*({dom:e,domSize0:t},{generation0:n}={}){if(null!=e)do{const{nextSibling:r}=e;s.get(e)===n&&(yield e,t--),e=r}while(t)}},c=f.delayedRemoval,d=f.domFor,p=function(t){var n,r,o=t&&t.document,l={svg:"http://www.w3.org/2000/svg",math:"http://www.w3.org/1998/Math/MathML"};function i(e){return e.attrs&&e.attrs.xmlns||l[e.tag]}function a(e,t){if(e.state!==t)throw new Error("'vnode.state' must not be modified.")}function u(e){var t=e.state;try{return this.apply(t,arguments)}finally{a(e,t)}}function s(){try{return o.activeElement}catch(e){return null}}function f(e,t,n,r,o,l,i){for(var a=n;a'+t.children+"",i=i.firstChild):i.innerHTML=t.children,t.dom=i.firstChild,t.domSize=i.childNodes.length;for(var a,u=o.createDocumentFragment();a=i.firstChild;)u.appendChild(a);k(e,u,r)}function h(e,t,n,r,o,l){if(t!==n&&(null!=t||null!=n))if(null==t||0===t.length)f(e,n,0,n.length,r,o,l);else if(null==n||0===n.length)E(e,t,0,t.length);else{var i=null!=t[0]&&null!=t[0].key,a=null!=n[0]&&null!=n[0].key,u=0,s=0;if(!i)for(;s=s&&S>=u&&(m=t[k],v=n[S],m.key===v.key);)m!==v&&y(e,m,v,r,o,l),null!=v.dom&&(o=v.dom),k--,S--;for(;k>=s&&S>=u&&(c=t[s],d=n[u],c.key===d.key);)s++,u++,c!==d&&y(e,c,d,r,b(t,s,o),l);for(;k>=s&&S>=u&&u!==S&&c.key===v.key&&m.key===d.key;)x(e,m,h=b(t,s,o)),m!==d&&y(e,m,d,r,h,l),++u<=--S&&x(e,c,o),c!==v&&y(e,c,v,r,o,l),null!=v.dom&&(o=v.dom),s++,m=t[--k],v=n[S],c=t[s],d=n[u];for(;k>=s&&S>=u&&m.key===v.key;)m!==v&&y(e,m,v,r,o,l),null!=v.dom&&(o=v.dom),S--,m=t[--k],v=n[S];if(u>S)E(e,t,s,k+1);else if(s>k)f(e,n,u,S+1,r,o,l);else{var j,A,C=o,O=S-u+1,T=new Array(O),N=0,$=0,L=2147483647,R=0;for($=0;$=u;$--){null==j&&(j=g(t,s,k+1));var I=j[(v=n[$]).key];null!=I&&(L=I>>1)+(r>>>1)+(n&r&1);e[t[a]]0&&(w[o]=t[n-1]),t[n]=o)}}n=t.length,r=t[n-1];for(;n-- >0;)t[n]=r,r=w[r];return w.length=0,t}(T)).length-1,$=S;$>=u;$--)d=n[$],-1===T[$-u]?p(e,d,r,l,o):A[N]===$-u?N--:x(e,d,o),null!=d.dom&&(o=n[$].dom);else for($=S;$>=u;$--)d=n[$],-1===T[$-u]&&p(e,d,r,l,o),null!=d.dom&&(o=n[$].dom)}}else{var P=t.lengthP&&E(e,t,u,t.length),n.length>P&&f(e,n,u,n.length,r,o,l)}}}function y(t,n,r,o,l,a){var s=n.tag;if(s===r.tag){if(r.state=n.state,r.events=n.events,function(e,t){do{var n;if(null!=e.attrs&&"function"==typeof e.attrs.onbeforeupdate)if(void 0!==(n=u.call(e.attrs.onbeforeupdate,e,t))&&!n)break;if("string"!=typeof e.tag&&"function"==typeof e.state.onbeforeupdate)if(void 0!==(n=u.call(e.state.onbeforeupdate,e,t))&&!n)break;return!1}while(0);return e.dom=t.dom,e.domSize=t.domSize,e.instance=t.instance,e.attrs=t.attrs,e.children=t.children,e.text=t.text,!0}(r,n))return;if("string"==typeof s)switch(null!=r.attrs&&M(r.attrs,r,o),s){case"#":!function(e,t){e.children.toString()!==t.children.toString()&&(e.dom.nodeValue=t.children);t.dom=e.dom}(n,r);break;case"<":!function(e,t,n,r,o){t.children!==n.children?(j(e,t,void 0),v(e,n,r,o)):(n.dom=t.dom,n.domSize=t.domSize)}(t,n,r,a,l);break;case"[":!function(e,t,n,r,o,l){h(e,t.children,n.children,r,o,l);var i=0,a=n.children;if(n.dom=null,null!=a){for(var u=0;u-1||null!=e.attrs&&e.attrs.is||"href"!==t&&"list"!==t&&"form"!==t&&"width"!==t&&"height"!==t)&&t in e.dom}var $,L=/[A-Z]/g;function R(e){return"-"+e.toLowerCase()}function I(e){return"-"===e[0]&&"-"===e[1]?e:"cssFloat"===e?"float":e.replace(L,R)}function P(e,t,n){if(t===n);else if(null==n)e.style="";else if("object"!=typeof n)e.style=n;else if(null==t||"object"!=typeof t)for(var r in e.style.cssText="",n){null!=(o=n[r])&&e.style.setProperty(I(r),String(o))}else{for(var r in n){var o;null!=(o=n[r])&&(o=String(o))!==String(t[r])&&e.style.setProperty(I(r),o)}for(var r in t)null!=t[r]&&null==n[r]&&e.style.removeProperty(I(r))}}function F(){this._=n}function _(e,t,r){if(null!=e.events){if(e.events._=n,e.events[t]===r)return;null==r||"function"!=typeof r&&"object"!=typeof r?(null!=e.events[t]&&e.dom.removeEventListener(t.slice(2),e.events,!1),e.events[t]=void 0):(null==e.events[t]&&e.dom.addEventListener(t.slice(2),e.events,!1),e.events[t]=r)}else null==r||"function"!=typeof r&&"object"!=typeof r||(e.events=new F,e.dom.addEventListener(t.slice(2),e.events,!1),e.events[t]=r)}function D(e,t,n){"function"==typeof e.oninit&&u.call(e.oninit,t),"function"==typeof e.oncreate&&n.push(u.bind(e.oncreate,t))}function M(e,t,n){"function"==typeof e.onupdate&&n.push(u.bind(e.onupdate,t))}return F.prototype=Object.create(null),F.prototype.handleEvent=function(e){var t,n=this["on"+e.type];"function"==typeof n?t=n.call(e.currentTarget,e):"function"==typeof n.handleEvent&&n.handleEvent(e),this._&&!1!==e.redraw&&(0,this._)(),!1===t&&(e.preventDefault(),e.stopPropagation())},function(t,o,l){if(!t)throw new TypeError("DOM element being rendered to does not exist.");if(null!=$&&t.contains($))throw new TypeError("Node is currently being rendered to and thus is locked.");var i=n,a=$,u=[],f=s(),c=t.namespaceURI;$=t,n="function"==typeof l?l:void 0,r={};try{null==t.vnodes&&(t.textContent=""),o=e.normalizeChildren(Array.isArray(o)?o:[o]),h(t,t.vnodes,o,u,null,"http://www.w3.org/1999/xhtml"===c?void 0:c),t.vnodes=o,null!=f&&s()!==f&&"function"==typeof f.focus&&f.focus();for(var d=0;d=0&&(o.splice(l,2),l<=i&&(i-=2),t(n,[])),null!=r&&(o.push(n,r),t(n,e(r),u))},redraw:u}}(p,"undefined"!=typeof requestAnimationFrame?requestAnimationFrame:null,"undefined"!=typeof console?console:null),v=function(e){if("[object Object]"!==Object.prototype.toString.call(e))return"";var t=[];for(var n in e)r(n,e[n]);return t.join("&");function r(e,n){if(Array.isArray(n))for(var o=0;o=0&&(p+=e.slice(n,o)),s>=0&&(p+=(n<0?"?":"&")+u.slice(s,c));var m=v(a);return m&&(p+=(n<0&&s<0?"?":"&")+m),r>=0&&(p+=e.slice(r)),f>=0&&(p+=(r<0?"":"&")+u.slice(f)),p},g=function(e,t){function r(e){return new Promise(e)}function o(e,t){for(var r in e.headers)if(n.call(e.headers,r)&&r.toLowerCase()===t)return!0;return!1}return r.prototype=Promise.prototype,r.__proto__=Promise,{request:function(l,i){"string"!=typeof l?(i=l,l=l.url):null==i&&(i={});var a=function(t,r){return new Promise((function(l,i){t=y(t,r.params);var a,u=null!=r.method?r.method.toUpperCase():"GET",s=r.body,f=(null==r.serialize||r.serialize===JSON.serialize)&&!(s instanceof e.FormData||s instanceof e.URLSearchParams),c=r.responseType||("function"==typeof r.extract?"":"json"),d=new e.XMLHttpRequest,p=!1,m=!1,v=d,h=d.abort;for(var g in d.abort=function(){p=!0,h.call(this)},d.open(u,t,!1!==r.async,"string"==typeof r.user?r.user:void 0,"string"==typeof r.password?r.password:void 0),f&&null!=s&&!o(r,"content-type")&&d.setRequestHeader("Content-Type","application/json; charset=utf-8"),"function"==typeof r.deserialize||o(r,"accept")||d.setRequestHeader("Accept","application/json, text/*"),r.withCredentials&&(d.withCredentials=r.withCredentials),r.timeout&&(d.timeout=r.timeout),d.responseType=c,r.headers)n.call(r.headers,g)&&d.setRequestHeader(g,r.headers[g]);d.onreadystatechange=function(e){if(!p&&4===e.target.readyState)try{var n,o=e.target.status>=200&&e.target.status<300||304===e.target.status||/^file:\/\//i.test(t),a=e.target.response;if("json"===c){if(!e.target.responseType&&"function"!=typeof r.extract)try{a=JSON.parse(e.target.responseText)}catch(e){a=null}}else c&&"text"!==c||null==a&&(a=e.target.responseText);if("function"==typeof r.extract?(a=r.extract(e.target,r),o=!0):"function"==typeof r.deserialize&&(a=r.deserialize(a)),o){if("function"==typeof r.type)if(Array.isArray(a))for(var u=0;u-1&&u.pop();for(var f=0;f { 8 | for (let i = 0; i < workerCount; i++) { 9 | const worker = new Worker('js/wordgenerator/worker.js', {type: 'module'}); 10 | worker.postMessage({type: 'load-module', module: hbModule}); 11 | 12 | workers[i] = { 13 | worker, 14 | available: true 15 | }; 16 | } 17 | }); 18 | 19 | function handleNextMessage() { 20 | if (queue.length) { 21 | const message = queue.shift(); 22 | postMessage(message.message, message.promise, message.trigger); 23 | } 24 | } 25 | 26 | function queueMessage(message, promise) { 27 | queue.push({message, promise}); 28 | } 29 | 30 | function postMessage(message, promise) { 31 | const worker = workers.find(worker => worker.available); 32 | 33 | if (!promise) promise = Deferred(); 34 | 35 | if (!worker) { 36 | queueMessage(message, promise); 37 | } else { 38 | worker.worker.postMessage(message); 39 | worker.worker.onmessage = (e) => { 40 | worker.available = true; 41 | promise.resolve(e); 42 | handleNextMessage(); 43 | } 44 | worker.available = false; 45 | } 46 | 47 | return promise; 48 | } 49 | 50 | return { 51 | postMessage 52 | } 53 | })(); 54 | 55 | function Deferred() { 56 | var res, rej; 57 | 58 | var promise = new Promise((resolve, reject) => { 59 | res = resolve; 60 | rej = reject; 61 | }); 62 | 63 | promise.resolve = res; 64 | promise.reject = rej; 65 | 66 | return promise; 67 | } -------------------------------------------------------------------------------- /js/wordgenerator/worker.js: -------------------------------------------------------------------------------- 1 | import { hbjs } from '../harfbuzzjs/hbjs.js'; 2 | import { Filters } from '../Filters.js'; 3 | 4 | let hb; 5 | 6 | onmessage = async function(e) { 7 | if (e.data.type === 'load-module') { 8 | const hbModule = e.data.module; 9 | hb = await WebAssembly.instantiate(hbModule, {}).then((instance) => { 10 | return hbjs(instance); 11 | }); 12 | } 13 | if (e.data.type === 'sort') { 14 | const words = e.data.words; 15 | const fontBuffer = e.data.fontBuffer; 16 | const fontFeaturesSettings = e.data.fontFeaturesSettings; 17 | const blob = hb.createBlob(fontBuffer) 18 | const face = hb.createFace(blob, 0); 19 | const font = hb.createFont(face); 20 | 21 | const sortedWords = measureWords(words, font, 100, fontFeaturesSettings); 22 | postMessage(sortedWords); 23 | } 24 | }; 25 | 26 | 27 | function measureWords(words, font, size, fontFeaturesSettings) { 28 | const scale = 1/(1000/size); 29 | const sortedWords = {}; 30 | sortedWords.minWidth = Infinity; 31 | font.setScale(1000, 1000); 32 | 33 | for (let filter of Filters) { 34 | sortedWords[filter.name] = {}; 35 | 36 | for (let word of words) { 37 | let filteredWord = filter.apply(word); 38 | let width = Math.floor(measureText(filteredWord, font, scale, fontFeaturesSettings)); 39 | if (sortedWords[filter.name][width] === undefined) { 40 | sortedWords[filter.name][width] = [] 41 | } 42 | sortedWords[filter.name][width].push(filteredWord) 43 | 44 | if (width < sortedWords.minWidth) sortedWords.minWidth = width; 45 | } 46 | 47 | sortedWords.spaceWidth = Math.floor(measureText(' ', font, scale, fontFeaturesSettings)); 48 | } 49 | 50 | return sortedWords; 51 | } 52 | 53 | function measureText(str, font, scale, fontFeaturesSettings) { 54 | const buffer = hb.createBuffer(); 55 | buffer.addText(str); 56 | buffer.guessSegmentProperties(); 57 | hb.shape(font, buffer, fontFeaturesSettings); 58 | const result = buffer.json(); 59 | buffer.destroy(); 60 | return result.reduce((acc, curr) => acc + curr.ax, 0)*scale; 61 | } -------------------------------------------------------------------------------- /license.md: -------------------------------------------------------------------------------- 1 | GNU General Public License 2 | ========================== 3 | 4 | _Version 3, 29 June 2007_ 5 | _Copyright © 2007 Free Software Foundation, Inc. <>_ 6 | 7 | Everyone is permitted to copy and distribute verbatim copies of this license 8 | document, but changing it is not allowed. 9 | 10 | ## Preamble 11 | 12 | The GNU General Public License is a free, copyleft license for software and other 13 | kinds of works. 14 | 15 | The licenses for most software and other practical works are designed to take away 16 | your freedom to share and change the works. By contrast, the GNU General Public 17 | License is intended to guarantee your freedom to share and change all versions of a 18 | program--to make sure it remains free software for all its users. We, the Free 19 | Software Foundation, use the GNU General Public License for most of our software; it 20 | applies also to any other work released this way by its authors. You can apply it to 21 | your programs, too. 22 | 23 | When we speak of free software, we are referring to freedom, not price. Our General 24 | Public Licenses are designed to make sure that you have the freedom to distribute 25 | copies of free software (and charge for them if you wish), that you receive source 26 | code or can get it if you want it, that you can change the software or use pieces of 27 | it in new free programs, and that you know you can do these things. 28 | 29 | To protect your rights, we need to prevent others from denying you these rights or 30 | asking you to surrender the rights. Therefore, you have certain responsibilities if 31 | you distribute copies of the software, or if you modify it: responsibilities to 32 | respect the freedom of others. 33 | 34 | For example, if you distribute copies of such a program, whether gratis or for a fee, 35 | you must pass on to the recipients the same freedoms that you received. You must make 36 | sure that they, too, receive or can get the source code. And you must show them these 37 | terms so they know their rights. 38 | 39 | Developers that use the GNU GPL protect your rights with two steps: **(1)** assert 40 | copyright on the software, and **(2)** offer you this License giving you legal permission 41 | to copy, distribute and/or modify it. 42 | 43 | For the developers' and authors' protection, the GPL clearly explains that there is 44 | no warranty for this free software. For both users' and authors' sake, the GPL 45 | requires that modified versions be marked as changed, so that their problems will not 46 | be attributed erroneously to authors of previous versions. 47 | 48 | Some devices are designed to deny users access to install or run modified versions of 49 | the software inside them, although the manufacturer can do so. This is fundamentally 50 | incompatible with the aim of protecting users' freedom to change the software. The 51 | systematic pattern of such abuse occurs in the area of products for individuals to 52 | use, which is precisely where it is most unacceptable. Therefore, we have designed 53 | this version of the GPL to prohibit the practice for those products. If such problems 54 | arise substantially in other domains, we stand ready to extend this provision to 55 | those domains in future versions of the GPL, as needed to protect the freedom of 56 | users. 57 | 58 | Finally, every program is threatened constantly by software patents. States should 59 | not allow patents to restrict development and use of software on general-purpose 60 | computers, but in those that do, we wish to avoid the special danger that patents 61 | applied to a free program could make it effectively proprietary. To prevent this, the 62 | GPL assures that patents cannot be used to render the program non-free. 63 | 64 | The precise terms and conditions for copying, distribution and modification follow. 65 | 66 | ## TERMS AND CONDITIONS 67 | 68 | ### 0. Definitions 69 | 70 | “This License” refers to version 3 of the GNU General Public License. 71 | 72 | “Copyright” also means copyright-like laws that apply to other kinds of 73 | works, such as semiconductor masks. 74 | 75 | “The Program” refers to any copyrightable work licensed under this 76 | License. Each licensee is addressed as “you”. “Licensees” and 77 | “recipients” may be individuals or organizations. 78 | 79 | To “modify” a work means to copy from or adapt all or part of the work in 80 | a fashion requiring copyright permission, other than the making of an exact copy. The 81 | resulting work is called a “modified version” of the earlier work or a 82 | work “based on” the earlier work. 83 | 84 | A “covered work” means either the unmodified Program or a work based on 85 | the Program. 86 | 87 | To “propagate” a work means to do anything with it that, without 88 | permission, would make you directly or secondarily liable for infringement under 89 | applicable copyright law, except executing it on a computer or modifying a private 90 | copy. Propagation includes copying, distribution (with or without modification), 91 | making available to the public, and in some countries other activities as well. 92 | 93 | To “convey” a work means any kind of propagation that enables other 94 | parties to make or receive copies. Mere interaction with a user through a computer 95 | network, with no transfer of a copy, is not conveying. 96 | 97 | An interactive user interface displays “Appropriate Legal Notices” to the 98 | extent that it includes a convenient and prominently visible feature that **(1)** 99 | displays an appropriate copyright notice, and **(2)** tells the user that there is no 100 | warranty for the work (except to the extent that warranties are provided), that 101 | licensees may convey the work under this License, and how to view a copy of this 102 | License. If the interface presents a list of user commands or options, such as a 103 | menu, a prominent item in the list meets this criterion. 104 | 105 | ### 1. Source Code 106 | 107 | The “source code” for a work means the preferred form of the work for 108 | making modifications to it. “Object code” means any non-source form of a 109 | work. 110 | 111 | A “Standard Interface” means an interface that either is an official 112 | standard defined by a recognized standards body, or, in the case of interfaces 113 | specified for a particular programming language, one that is widely used among 114 | developers working in that language. 115 | 116 | The “System Libraries” of an executable work include anything, other than 117 | the work as a whole, that **(a)** is included in the normal form of packaging a Major 118 | Component, but which is not part of that Major Component, and **(b)** serves only to 119 | enable use of the work with that Major Component, or to implement a Standard 120 | Interface for which an implementation is available to the public in source code form. 121 | A “Major Component”, in this context, means a major essential component 122 | (kernel, window system, and so on) of the specific operating system (if any) on which 123 | the executable work runs, or a compiler used to produce the work, or an object code 124 | interpreter used to run it. 125 | 126 | The “Corresponding Source” for a work in object code form means all the 127 | source code needed to generate, install, and (for an executable work) run the object 128 | code and to modify the work, including scripts to control those activities. However, 129 | it does not include the work's System Libraries, or general-purpose tools or 130 | generally available free programs which are used unmodified in performing those 131 | activities but which are not part of the work. For example, Corresponding Source 132 | includes interface definition files associated with source files for the work, and 133 | the source code for shared libraries and dynamically linked subprograms that the work 134 | is specifically designed to require, such as by intimate data communication or 135 | control flow between those subprograms and other parts of the work. 136 | 137 | The Corresponding Source need not include anything that users can regenerate 138 | automatically from other parts of the Corresponding Source. 139 | 140 | The Corresponding Source for a work in source code form is that same work. 141 | 142 | ### 2. Basic Permissions 143 | 144 | All rights granted under this License are granted for the term of copyright on the 145 | Program, and are irrevocable provided the stated conditions are met. This License 146 | explicitly affirms your unlimited permission to run the unmodified Program. The 147 | output from running a covered work is covered by this License only if the output, 148 | given its content, constitutes a covered work. This License acknowledges your rights 149 | of fair use or other equivalent, as provided by copyright law. 150 | 151 | You may make, run and propagate covered works that you do not convey, without 152 | conditions so long as your license otherwise remains in force. You may convey covered 153 | works to others for the sole purpose of having them make modifications exclusively 154 | for you, or provide you with facilities for running those works, provided that you 155 | comply with the terms of this License in conveying all material for which you do not 156 | control copyright. Those thus making or running the covered works for you must do so 157 | exclusively on your behalf, under your direction and control, on terms that prohibit 158 | them from making any copies of your copyrighted material outside their relationship 159 | with you. 160 | 161 | Conveying under any other circumstances is permitted solely under the conditions 162 | stated below. Sublicensing is not allowed; section 10 makes it unnecessary. 163 | 164 | ### 3. Protecting Users' Legal Rights From Anti-Circumvention Law 165 | 166 | No covered work shall be deemed part of an effective technological measure under any 167 | applicable law fulfilling obligations under article 11 of the WIPO copyright treaty 168 | adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention 169 | of such measures. 170 | 171 | When you convey a covered work, you waive any legal power to forbid circumvention of 172 | technological measures to the extent such circumvention is effected by exercising 173 | rights under this License with respect to the covered work, and you disclaim any 174 | intention to limit operation or modification of the work as a means of enforcing, 175 | against the work's users, your or third parties' legal rights to forbid circumvention 176 | of technological measures. 177 | 178 | ### 4. Conveying Verbatim Copies 179 | 180 | You may convey verbatim copies of the Program's source code as you receive it, in any 181 | medium, provided that you conspicuously and appropriately publish on each copy an 182 | appropriate copyright notice; keep intact all notices stating that this License and 183 | any non-permissive terms added in accord with section 7 apply to the code; keep 184 | intact all notices of the absence of any warranty; and give all recipients a copy of 185 | this License along with the Program. 186 | 187 | You may charge any price or no price for each copy that you convey, and you may offer 188 | support or warranty protection for a fee. 189 | 190 | ### 5. Conveying Modified Source Versions 191 | 192 | You may convey a work based on the Program, or the modifications to produce it from 193 | the Program, in the form of source code under the terms of section 4, provided that 194 | you also meet all of these conditions: 195 | 196 | * **a)** The work must carry prominent notices stating that you modified it, and giving a 197 | relevant date. 198 | * **b)** The work must carry prominent notices stating that it is released under this 199 | License and any conditions added under section 7. This requirement modifies the 200 | requirement in section 4 to “keep intact all notices”. 201 | * **c)** You must license the entire work, as a whole, under this License to anyone who 202 | comes into possession of a copy. This License will therefore apply, along with any 203 | applicable section 7 additional terms, to the whole of the work, and all its parts, 204 | regardless of how they are packaged. This License gives no permission to license the 205 | work in any other way, but it does not invalidate such permission if you have 206 | separately received it. 207 | * **d)** If the work has interactive user interfaces, each must display Appropriate Legal 208 | Notices; however, if the Program has interactive interfaces that do not display 209 | Appropriate Legal Notices, your work need not make them do so. 210 | 211 | A compilation of a covered work with other separate and independent works, which are 212 | not by their nature extensions of the covered work, and which are not combined with 213 | it such as to form a larger program, in or on a volume of a storage or distribution 214 | medium, is called an “aggregate” if the compilation and its resulting 215 | copyright are not used to limit the access or legal rights of the compilation's users 216 | beyond what the individual works permit. Inclusion of a covered work in an aggregate 217 | does not cause this License to apply to the other parts of the aggregate. 218 | 219 | ### 6. Conveying Non-Source Forms 220 | 221 | You may convey a covered work in object code form under the terms of sections 4 and 222 | 5, provided that you also convey the machine-readable Corresponding Source under the 223 | terms of this License, in one of these ways: 224 | 225 | * **a)** Convey the object code in, or embodied in, a physical product (including a 226 | physical distribution medium), accompanied by the Corresponding Source fixed on a 227 | durable physical medium customarily used for software interchange. 228 | * **b)** Convey the object code in, or embodied in, a physical product (including a 229 | physical distribution medium), accompanied by a written offer, valid for at least 230 | three years and valid for as long as you offer spare parts or customer support for 231 | that product model, to give anyone who possesses the object code either **(1)** a copy of 232 | the Corresponding Source for all the software in the product that is covered by this 233 | License, on a durable physical medium customarily used for software interchange, for 234 | a price no more than your reasonable cost of physically performing this conveying of 235 | source, or **(2)** access to copy the Corresponding Source from a network server at no 236 | charge. 237 | * **c)** Convey individual copies of the object code with a copy of the written offer to 238 | provide the Corresponding Source. This alternative is allowed only occasionally and 239 | noncommercially, and only if you received the object code with such an offer, in 240 | accord with subsection 6b. 241 | * **d)** Convey the object code by offering access from a designated place (gratis or for 242 | a charge), and offer equivalent access to the Corresponding Source in the same way 243 | through the same place at no further charge. You need not require recipients to copy 244 | the Corresponding Source along with the object code. If the place to copy the object 245 | code is a network server, the Corresponding Source may be on a different server 246 | (operated by you or a third party) that supports equivalent copying facilities, 247 | provided you maintain clear directions next to the object code saying where to find 248 | the Corresponding Source. Regardless of what server hosts the Corresponding Source, 249 | you remain obligated to ensure that it is available for as long as needed to satisfy 250 | these requirements. 251 | * **e)** Convey the object code using peer-to-peer transmission, provided you inform 252 | other peers where the object code and Corresponding Source of the work are being 253 | offered to the general public at no charge under subsection 6d. 254 | 255 | A separable portion of the object code, whose source code is excluded from the 256 | Corresponding Source as a System Library, need not be included in conveying the 257 | object code work. 258 | 259 | A “User Product” is either **(1)** a “consumer product”, which 260 | means any tangible personal property which is normally used for personal, family, or 261 | household purposes, or **(2)** anything designed or sold for incorporation into a 262 | dwelling. In determining whether a product is a consumer product, doubtful cases 263 | shall be resolved in favor of coverage. For a particular product received by a 264 | particular user, “normally used” refers to a typical or common use of 265 | that class of product, regardless of the status of the particular user or of the way 266 | in which the particular user actually uses, or expects or is expected to use, the 267 | product. A product is a consumer product regardless of whether the product has 268 | substantial commercial, industrial or non-consumer uses, unless such uses represent 269 | the only significant mode of use of the product. 270 | 271 | “Installation Information” for a User Product means any methods, 272 | procedures, authorization keys, or other information required to install and execute 273 | modified versions of a covered work in that User Product from a modified version of 274 | its Corresponding Source. The information must suffice to ensure that the continued 275 | functioning of the modified object code is in no case prevented or interfered with 276 | solely because modification has been made. 277 | 278 | If you convey an object code work under this section in, or with, or specifically for 279 | use in, a User Product, and the conveying occurs as part of a transaction in which 280 | the right of possession and use of the User Product is transferred to the recipient 281 | in perpetuity or for a fixed term (regardless of how the transaction is 282 | characterized), the Corresponding Source conveyed under this section must be 283 | accompanied by the Installation Information. But this requirement does not apply if 284 | neither you nor any third party retains the ability to install modified object code 285 | on the User Product (for example, the work has been installed in ROM). 286 | 287 | The requirement to provide Installation Information does not include a requirement to 288 | continue to provide support service, warranty, or updates for a work that has been 289 | modified or installed by the recipient, or for the User Product in which it has been 290 | modified or installed. Access to a network may be denied when the modification itself 291 | materially and adversely affects the operation of the network or violates the rules 292 | and protocols for communication across the network. 293 | 294 | Corresponding Source conveyed, and Installation Information provided, in accord with 295 | this section must be in a format that is publicly documented (and with an 296 | implementation available to the public in source code form), and must require no 297 | special password or key for unpacking, reading or copying. 298 | 299 | ### 7. Additional Terms 300 | 301 | “Additional permissions” are terms that supplement the terms of this 302 | License by making exceptions from one or more of its conditions. Additional 303 | permissions that are applicable to the entire Program shall be treated as though they 304 | were included in this License, to the extent that they are valid under applicable 305 | law. If additional permissions apply only to part of the Program, that part may be 306 | used separately under those permissions, but the entire Program remains governed by 307 | this License without regard to the additional permissions. 308 | 309 | When you convey a copy of a covered work, you may at your option remove any 310 | additional permissions from that copy, or from any part of it. (Additional 311 | permissions may be written to require their own removal in certain cases when you 312 | modify the work.) You may place additional permissions on material, added by you to a 313 | covered work, for which you have or can give appropriate copyright permission. 314 | 315 | Notwithstanding any other provision of this License, for material you add to a 316 | covered work, you may (if authorized by the copyright holders of that material) 317 | supplement the terms of this License with terms: 318 | 319 | * **a)** Disclaiming warranty or limiting liability differently from the terms of 320 | sections 15 and 16 of this License; or 321 | * **b)** Requiring preservation of specified reasonable legal notices or author 322 | attributions in that material or in the Appropriate Legal Notices displayed by works 323 | containing it; or 324 | * **c)** Prohibiting misrepresentation of the origin of that material, or requiring that 325 | modified versions of such material be marked in reasonable ways as different from the 326 | original version; or 327 | * **d)** Limiting the use for publicity purposes of names of licensors or authors of the 328 | material; or 329 | * **e)** Declining to grant rights under trademark law for use of some trade names, 330 | trademarks, or service marks; or 331 | * **f)** Requiring indemnification of licensors and authors of that material by anyone 332 | who conveys the material (or modified versions of it) with contractual assumptions of 333 | liability to the recipient, for any liability that these contractual assumptions 334 | directly impose on those licensors and authors. 335 | 336 | All other non-permissive additional terms are considered “further 337 | restrictions” within the meaning of section 10. If the Program as you received 338 | it, or any part of it, contains a notice stating that it is governed by this License 339 | along with a term that is a further restriction, you may remove that term. If a 340 | license document contains a further restriction but permits relicensing or conveying 341 | under this License, you may add to a covered work material governed by the terms of 342 | that license document, provided that the further restriction does not survive such 343 | relicensing or conveying. 344 | 345 | If you add terms to a covered work in accord with this section, you must place, in 346 | the relevant source files, a statement of the additional terms that apply to those 347 | files, or a notice indicating where to find the applicable terms. 348 | 349 | Additional terms, permissive or non-permissive, may be stated in the form of a 350 | separately written license, or stated as exceptions; the above requirements apply 351 | either way. 352 | 353 | ### 8. Termination 354 | 355 | You may not propagate or modify a covered work except as expressly provided under 356 | this License. Any attempt otherwise to propagate or modify it is void, and will 357 | automatically terminate your rights under this License (including any patent licenses 358 | granted under the third paragraph of section 11). 359 | 360 | However, if you cease all violation of this License, then your license from a 361 | particular copyright holder is reinstated **(a)** provisionally, unless and until the 362 | copyright holder explicitly and finally terminates your license, and **(b)** permanently, 363 | if the copyright holder fails to notify you of the violation by some reasonable means 364 | prior to 60 days after the cessation. 365 | 366 | Moreover, your license from a particular copyright holder is reinstated permanently 367 | if the copyright holder notifies you of the violation by some reasonable means, this 368 | is the first time you have received notice of violation of this License (for any 369 | work) from that copyright holder, and you cure the violation prior to 30 days after 370 | your receipt of the notice. 371 | 372 | Termination of your rights under this section does not terminate the licenses of 373 | parties who have received copies or rights from you under this License. If your 374 | rights have been terminated and not permanently reinstated, you do not qualify to 375 | receive new licenses for the same material under section 10. 376 | 377 | ### 9. Acceptance Not Required for Having Copies 378 | 379 | You are not required to accept this License in order to receive or run a copy of the 380 | Program. Ancillary propagation of a covered work occurring solely as a consequence of 381 | using peer-to-peer transmission to receive a copy likewise does not require 382 | acceptance. However, nothing other than this License grants you permission to 383 | propagate or modify any covered work. These actions infringe copyright if you do not 384 | accept this License. Therefore, by modifying or propagating a covered work, you 385 | indicate your acceptance of this License to do so. 386 | 387 | ### 10. Automatic Licensing of Downstream Recipients 388 | 389 | Each time you convey a covered work, the recipient automatically receives a license 390 | from the original licensors, to run, modify and propagate that work, subject to this 391 | License. You are not responsible for enforcing compliance by third parties with this 392 | License. 393 | 394 | An “entity transaction” is a transaction transferring control of an 395 | organization, or substantially all assets of one, or subdividing an organization, or 396 | merging organizations. If propagation of a covered work results from an entity 397 | transaction, each party to that transaction who receives a copy of the work also 398 | receives whatever licenses to the work the party's predecessor in interest had or 399 | could give under the previous paragraph, plus a right to possession of the 400 | Corresponding Source of the work from the predecessor in interest, if the predecessor 401 | has it or can get it with reasonable efforts. 402 | 403 | You may not impose any further restrictions on the exercise of the rights granted or 404 | affirmed under this License. For example, you may not impose a license fee, royalty, 405 | or other charge for exercise of rights granted under this License, and you may not 406 | initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging 407 | that any patent claim is infringed by making, using, selling, offering for sale, or 408 | importing the Program or any portion of it. 409 | 410 | ### 11. Patents 411 | 412 | A “contributor” is a copyright holder who authorizes use under this 413 | License of the Program or a work on which the Program is based. The work thus 414 | licensed is called the contributor's “contributor version”. 415 | 416 | A contributor's “essential patent claims” are all patent claims owned or 417 | controlled by the contributor, whether already acquired or hereafter acquired, that 418 | would be infringed by some manner, permitted by this License, of making, using, or 419 | selling its contributor version, but do not include claims that would be infringed 420 | only as a consequence of further modification of the contributor version. For 421 | purposes of this definition, “control” includes the right to grant patent 422 | sublicenses in a manner consistent with the requirements of this License. 423 | 424 | Each contributor grants you a non-exclusive, worldwide, royalty-free patent license 425 | under the contributor's essential patent claims, to make, use, sell, offer for sale, 426 | import and otherwise run, modify and propagate the contents of its contributor 427 | version. 428 | 429 | In the following three paragraphs, a “patent license” is any express 430 | agreement or commitment, however denominated, not to enforce a patent (such as an 431 | express permission to practice a patent or covenant not to sue for patent 432 | infringement). To “grant” such a patent license to a party means to make 433 | such an agreement or commitment not to enforce a patent against the party. 434 | 435 | If you convey a covered work, knowingly relying on a patent license, and the 436 | Corresponding Source of the work is not available for anyone to copy, free of charge 437 | and under the terms of this License, through a publicly available network server or 438 | other readily accessible means, then you must either **(1)** cause the Corresponding 439 | Source to be so available, or **(2)** arrange to deprive yourself of the benefit of the 440 | patent license for this particular work, or **(3)** arrange, in a manner consistent with 441 | the requirements of this License, to extend the patent license to downstream 442 | recipients. “Knowingly relying” means you have actual knowledge that, but 443 | for the patent license, your conveying the covered work in a country, or your 444 | recipient's use of the covered work in a country, would infringe one or more 445 | identifiable patents in that country that you have reason to believe are valid. 446 | 447 | If, pursuant to or in connection with a single transaction or arrangement, you 448 | convey, or propagate by procuring conveyance of, a covered work, and grant a patent 449 | license to some of the parties receiving the covered work authorizing them to use, 450 | propagate, modify or convey a specific copy of the covered work, then the patent 451 | license you grant is automatically extended to all recipients of the covered work and 452 | works based on it. 453 | 454 | A patent license is “discriminatory” if it does not include within the 455 | scope of its coverage, prohibits the exercise of, or is conditioned on the 456 | non-exercise of one or more of the rights that are specifically granted under this 457 | License. You may not convey a covered work if you are a party to an arrangement with 458 | a third party that is in the business of distributing software, under which you make 459 | payment to the third party based on the extent of your activity of conveying the 460 | work, and under which the third party grants, to any of the parties who would receive 461 | the covered work from you, a discriminatory patent license **(a)** in connection with 462 | copies of the covered work conveyed by you (or copies made from those copies), or **(b)** 463 | primarily for and in connection with specific products or compilations that contain 464 | the covered work, unless you entered into that arrangement, or that patent license 465 | was granted, prior to 28 March 2007. 466 | 467 | Nothing in this License shall be construed as excluding or limiting any implied 468 | license or other defenses to infringement that may otherwise be available to you 469 | under applicable patent law. 470 | 471 | ### 12. No Surrender of Others' Freedom 472 | 473 | If conditions are imposed on you (whether by court order, agreement or otherwise) 474 | that contradict the conditions of this License, they do not excuse you from the 475 | conditions of this License. If you cannot convey a covered work so as to satisfy 476 | simultaneously your obligations under this License and any other pertinent 477 | obligations, then as a consequence you may not convey it at all. For example, if you 478 | agree to terms that obligate you to collect a royalty for further conveying from 479 | those to whom you convey the Program, the only way you could satisfy both those terms 480 | and this License would be to refrain entirely from conveying the Program. 481 | 482 | ### 13. Use with the GNU Affero General Public License 483 | 484 | Notwithstanding any other provision of this License, you have permission to link or 485 | combine any covered work with a work licensed under version 3 of the GNU Affero 486 | General Public License into a single combined work, and to convey the resulting work. 487 | The terms of this License will continue to apply to the part which is the covered 488 | work, but the special requirements of the GNU Affero General Public License, section 489 | 13, concerning interaction through a network will apply to the combination as such. 490 | 491 | ### 14. Revised Versions of this License 492 | 493 | The Free Software Foundation may publish revised and/or new versions of the GNU 494 | General Public License from time to time. Such new versions will be similar in spirit 495 | to the present version, but may differ in detail to address new problems or concerns. 496 | 497 | Each version is given a distinguishing version number. If the Program specifies that 498 | a certain numbered version of the GNU General Public License “or any later 499 | version” applies to it, you have the option of following the terms and 500 | conditions either of that numbered version or of any later version published by the 501 | Free Software Foundation. If the Program does not specify a version number of the GNU 502 | General Public License, you may choose any version ever published by the Free 503 | Software Foundation. 504 | 505 | If the Program specifies that a proxy can decide which future versions of the GNU 506 | General Public License can be used, that proxy's public statement of acceptance of a 507 | version permanently authorizes you to choose that version for the Program. 508 | 509 | Later license versions may give you additional or different permissions. However, no 510 | additional obligations are imposed on any author or copyright holder as a result of 511 | your choosing to follow a later version. 512 | 513 | ### 15. Disclaimer of Warranty 514 | 515 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. 516 | EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES 517 | PROVIDE THE PROGRAM “AS IS” WITHOUT WARRANTY OF ANY KIND, EITHER 518 | EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF 519 | MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE 520 | QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE 521 | DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 522 | 523 | ### 16. Limitation of Liability 524 | 525 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY 526 | COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS 527 | PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, 528 | INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE 529 | PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE 530 | OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE 531 | WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE 532 | POSSIBILITY OF SUCH DAMAGES. 533 | 534 | ### 17. Interpretation of Sections 15 and 16 535 | 536 | If the disclaimer of warranty and limitation of liability provided above cannot be 537 | given local legal effect according to their terms, reviewing courts shall apply local 538 | law that most closely approximates an absolute waiver of all civil liability in 539 | connection with the Program, unless a warranty or assumption of liability accompanies 540 | a copy of the Program in return for a fee. 541 | 542 | _END OF TERMS AND CONDITIONS_ 543 | 544 | ## How to Apply These Terms to Your New Programs 545 | 546 | If you develop a new program, and you want it to be of the greatest possible use to 547 | the public, the best way to achieve this is to make it free software which everyone 548 | can redistribute and change under these terms. 549 | 550 | To do so, attach the following notices to the program. It is safest to attach them 551 | to the start of each source file to most effectively state the exclusion of warranty; 552 | and each file should have at least the “copyright” line and a pointer to 553 | where the full notice is found. 554 | 555 | 556 | Copyright (C) 557 | 558 | This program is free software: you can redistribute it and/or modify 559 | it under the terms of the GNU General Public License as published by 560 | the Free Software Foundation, either version 3 of the License, or 561 | (at your option) any later version. 562 | 563 | This program is distributed in the hope that it will be useful, 564 | but WITHOUT ANY WARRANTY; without even the implied warranty of 565 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 566 | GNU General Public License for more details. 567 | 568 | You should have received a copy of the GNU General Public License 569 | along with this program. If not, see . 570 | 571 | Also add information on how to contact you by electronic and paper mail. 572 | 573 | If the program does terminal interaction, make it output a short notice like this 574 | when it starts in an interactive mode: 575 | 576 | Copyright (C) 577 | This program comes with ABSOLUTELY NO WARRANTY; for details type 'show w'. 578 | This is free software, and you are welcome to redistribute it 579 | under certain conditions; type 'show c' for details. 580 | 581 | The hypothetical commands `show w` and `show c` should show the appropriate parts of 582 | the General Public License. Of course, your program's commands might be different; 583 | for a GUI interface, you would use an “about box”. 584 | 585 | You should also get your employer (if you work as a programmer) or school, if any, to 586 | sign a “copyright disclaimer” for the program, if necessary. For more 587 | information on this, and how to apply and follow the GNU GPL, see 588 | <>. 589 | 590 | The GNU General Public License does not permit incorporating your program into 591 | proprietary programs. If your program is a subroutine library, you may consider it 592 | more useful to permit linking proprietary applications with the library. If this is 593 | what you want to do, use the GNU Lesser General Public License instead of this 594 | License. But first, please read 595 | <>. 596 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | Stack & Justify 2 | =============== 3 | 4 | ![Stack & Justify screenshot](/images/screenshot.png?raw=true) 5 | 6 | Stack & Justify is a tool to help create type specimens by finding words or phrases of the same width. It is free to use and distributed under GPLv3 license. 7 | 8 | Font files are not uploaded, they remain stored locally in your browser. 9 | 10 | For a similar tool, also check Mass Driver’s Waterfall from which this tool was inspired. -------------------------------------------------------------------------------- /svg/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /words/wikipedia/is_wikipedia.json: -------------------------------------------------------------------------------- 1 | { 2 | "words": [ 3 | "Forsíða", 4 | "Carles Puigdemont", 5 | "Marie Antoinette", 6 | "Matt Groening", 7 | "Fiann Paul", 8 | "Fullveldisdagurinn", 9 | "Upernavik", 10 | "Fríverslunarsamtök Evrópu", 11 | "Alfreð Finnbogason", 12 | "Bandaríska frelsisstríðið", 13 | "Mynstur", 14 | "Fluga", 15 | "Forgotten Lores", 16 | "Gjálp og Greip", 17 | "Guðbjörg Matthíasdóttir", 18 | "Hagfræði", 19 | "Haust", 20 | "Háskóli Íslands", 21 | "Mannshvörf á Íslandi", 22 | "Mervíkingar", 23 | "Milton Friedman", 24 | "Palestína", 25 | "Taylor Swift", 26 | "Wiki", 27 | "Wikiheimild", 28 | "YouTube", 29 | "Írafár", 30 | "Íslensku tónlistarverðlaunin 2023", 31 | "-", 32 | "1214", 33 | "1243", 34 | "1752", 35 | "1994", 36 | "20. nóvember", 37 | "Almenningur", 38 | "Alþjóðasiglingamálastofnunin", 39 | "Apple Macintosh", 40 | "Baldur", 41 | "Bangladess", 42 | "Bor", 43 | "Bragi", 44 | "Búri", 45 | "DV", 46 | "Dellingur", 47 | "Dóminíka", 48 | "Eiginfjárhlutfall", 49 | "Eigið fé", 50 | "Einhildur", 51 | "Eldgosið við Fagradalsfjall 2021", 52 | "Forseti", 53 | "Geimgreftrun", 54 | "Gervigreind", 55 | "Gildishlaðinn texti", 56 | "Gjálpargosið", 57 | "Greni", 58 | "Gullbringusýsla", 59 | "Gunnar Dal", 60 | "Heimdallur", 61 | "Hernám Íslands", 62 | "Hákon Sverrisson", 63 | "IP-tala", 64 | "Iðnbyltingin", 65 | "Jarðvarmavirkjun", 66 | "Joe Biden", 67 | "Joseph Conrad", 68 | "Jón Sigurðsson frá Kaldaðarnesi", 69 | "Jövu-nashyrningur", 70 | "Karlungar", 71 | "Krímstríðið", 72 | "Leirkeragerð", 73 | "Lind", 74 | "Lögbundnir frídagar á Íslandi", 75 | "Lögmaður", 76 | "Mbl.is", 77 | "Metri", 78 | "Miltisbrandur", 79 | "Morgunblaðið", 80 | "Mosar", 81 | "Mínóísk menning", 82 | "Nýlenda", 83 | "Palestínuríki", 84 | "Rangárvallasýsla", 85 | "Richard Stallman", 86 | "Salka Valka", 87 | "Scafell Pike", 88 | "Scaled and Icy", 89 | "Shane MacGowan", 90 | "Sjálfstæðisyfirlýsing Bandaríkjanna", 91 | "Sturlungaöld", 92 | "Stétt", 93 | "Sýslur Íslands", 94 | "Teikniborð", 95 | "The Pirate Bay", 96 | "Timbur", 97 | "Tjörnes", 98 | "Upplýsingar", 99 | "Vísir", 100 | "Wayback Machine", 101 | "Ísland", 102 | "Ísland í seinni heimsstyrjöldinni", 103 | "Íslensk sveitarfélög eftir mannfjölda", 104 | "Órestes", 105 | "Þingeyjarsýsla", 106 | "Þórðarhyrna", 107 | ".hm", 108 | ".mk", 109 | "1. deild karla í knattspyrnu 1991", 110 | "1. desember", 111 | "1000", 112 | "1164", 113 | "1211", 114 | "1670", 115 | "1835", 116 | "1856", 117 | "1903", 118 | "1924", 119 | "1925", 120 | "1930", 121 | "1941", 122 | "1945", 123 | "1990", 124 | "4. deild karla í knattspyrnu", 125 | "500", 126 | "8. ágúst", 127 | "ABBA", 128 | "Abraham", 129 | "Adam Sandler", 130 | "Addisonveiki", 131 | "Airbus", 132 | "Akkadíska", 133 | "Aldursgreining með geislunarmælingu", 134 | "Alfræðirit", 135 | "Alkibíades", 136 | "Almannavalsfræði", 137 | "Alsdorf", 138 | "Alþingiskosningar 2021", 139 | "Alþjóðasamningur um öryggi mannslífa á hafinu", 140 | "Andesít", 141 | "Anjem Choudary", 142 | "Armeria duriaei", 143 | "Arnór Sigurðsson", 144 | "Assamíska", 145 | "Athygli", 146 | "Axel", 147 | "Aða", 148 | "Aðall", 149 | "Aðferðaminni", 150 | "Bangsímon", 151 | "Bankahrunið á Íslandi", 152 | "Barein", 153 | "Basalt", 154 | "Beiting", 155 | "Benedikt Erlingsson", 156 | "Berklar", 157 | "Bermúdaþríhyrningurinn", 158 | "Berta", 159 | "Betlehemstjarna", 160 | "Bikarkeppni kvenna í knattspyrnu", 161 | "Binni Glee", 162 | "Bishkek", 163 | "Bjarni Pálsson", 164 | "Björgunarbátur", 165 | "Blaðamennska", 166 | "Bluetooth", 167 | "Bláa lónið", 168 | "Blágreni", 169 | "Blóðberg", 170 | "Blóðrásarkerfið", 171 | "Blóðörn", 172 | "Bolludagur", 173 | "Borgarfjarðarsýsla", 174 | "Botsvana", 175 | "Boys Like Girls", 176 | "Breiðablik UBK", 177 | "Brekkugoði", 178 | "Brendon Small", 179 | "Bretland", 180 | "Breyta", 181 | "Brissafi", 182 | "Bruce Dickinson", 183 | "Brynja Þorgeirsdóttir", 184 | "Bríet Bjarnhéðinsdóttir", 185 | "Brúðkaupsafmæli", 186 | "Bær", 187 | "Bók", 188 | "Búddismi", 189 | "Búrúndí", 190 | "Bútan", 191 | "Charles Cecil", 192 | "Charlie Hebdo", 193 | "David Villa", 194 | "Deildartunguhver", 195 | "Demókrítos", 196 | "Desembristauppreisnin", 197 | "Disney+", 198 | "Douglas Adams", 199 | "Droplaug", 200 | "Dulfrævingar", 201 | "Dyflinni", 202 | "Dósaverksmiðjan", 203 | "Dúbaí", 204 | "E. E. Evans-Pritchard", 205 | "Edda Heiðrún Backman", 206 | "Edduverðlaunin", 207 | "Efndir in natura", 208 | "Egg", 209 | "Egon Friedell", 210 | "Egypska byltingin 2011", 211 | "Eiginnafn", 212 | "Eign", 213 | "Eimreiðarhópurinn", 214 | "Einar Kárason", 215 | "Einhverfa", 216 | "Einkaskóli", 217 | "Eiturvirkni", 218 | "Eiturþörungar", 219 | "Eldgosið við Litla-Hrút 2023", 220 | "Electric Six", 221 | "Elizabeth", 222 | "Emily Dickinson", 223 | "Endurskoðunarstefna", 224 | "Eðlismassi", 225 | "Fastanefndir Alþingis", 226 | "Ferðamaður", 227 | "Fjallagrös", 228 | "Fjallkonan", 229 | "Fjölbrautaskólinn í Breiðholti", 230 | "Fjölgreindakenningin", 231 | "Fjöruspói", 232 | "Flosi Þórðarson", 233 | "Flæðafura", 234 | "Fornbókabúðin", 235 | "Forsætisráðherra Íslands", 236 | "Frans 2.", 237 | "Fransk-prússneska stríðið", 238 | "Franska byltingin", 239 | "Friðrik 1. Danakonungur", 240 | "Friðrik 2. Danakonungur", 241 | "Friðrik 3. Danakonungur", 242 | "Friðrik 4. Danakonungur", 243 | "Friðrik 5. Danakonungur", 244 | "Friðrik barbarossa", 245 | "Friðrik Ólafsson", 246 | "Frostaveturinn mikli", 247 | "Frostaveturinn mikli 1917-18", 248 | "Frumlag", 249 | "Frá vöggu til vöggu", 250 | "Fríða Á. Sigurðardóttir", 251 | "Félagseyjar", 252 | "Fólínsýra", 253 | "Gagnagrunnur", 254 | "Garðabær", 255 | "George Tiller", 256 | "Gerlar", 257 | "Gigt", 258 | "Ginnungagap", 259 | "Gjaldþrot", 260 | "Gland", 261 | "Glaumbær", 262 | "Glúkósi", 263 | "Goodluck Jonathan", 264 | "Gosberg", 265 | "Gotar", 266 | "Greenwich", 267 | "Gregoríska tímatalið", 268 | "Grettir Ásmundarson", 269 | "Grettis saga", 270 | "Grikkland", 271 | "Grikkland hið forna", 272 | "Grund", 273 | "Grábrók", 274 | "Grænserkur", 275 | "Gróðurhúsalofttegund", 276 | "Gröftur", 277 | "Gufunes", 278 | "Gullbringu- og Kjósarsýsla", 279 | "Gunnólfsvíkurfjall", 280 | "Guðmundur Gunnarsson", 281 | "Guðmundur Ólafsson", 282 | "Gísli Pálmi", 283 | "Hafdís Huld Þrastardóttir", 284 | "Hallmundarhraun", 285 | "Han-ættin", 286 | "Hannes Hólmsteinn Gissurarson", 287 | "Hans Danakonungur", 288 | "Hattur", 289 | "Heidi Klum", 290 | "Heimsvaldastefna", 291 | "Heinrich Himmler", 292 | "Heiðabær", 293 | "Heiðar Guðjónsson", 294 | "Hektari", 295 | "Helförin", 296 | "Helga Sigurðardóttir", 297 | "Helgi Björnsson", 298 | "Hellisheiðarvirkjun", 299 | "Helvíti", 300 | "Hernán Cortés", 301 | "Hinrik", 302 | "Hitadeigt plast", 303 | "Hljómar - Ertu með?", 304 | "Hljómsveit Ingimars Eydal - Á sjó", 305 | "Hljóðan", 306 | "Hljóðvarp", 307 | "Hofsós", 308 | "Hormón", 309 | "Hornbjarg", 310 | "Hrafnagil", 311 | "Hraunfossar", 312 | "Hreyfing", 313 | "Hugræn sálfræði", 314 | "Hugsvinnsmál", 315 | "Háhitasvæði", 316 | "Háskóli", 317 | "Hættir sagna í íslensku", 318 | "Hólar í Hjaltadal", 319 | "Hóstarkirtill", 320 | "Höfði", 321 | "IPod touch", 322 | "Immanuel Kant", 323 | "Indverski þjóðarráðsflokkurinn", 324 | "Ingvar E. Sigurðsson", 325 | "Ivanka Trump", 326 | "Jakobsvegurinn", 327 | "Japanska", 328 | "Jarðkeppur", 329 | "Jaspis", 330 | "Javier Milei", 331 | "Jean Charles de Menezes", 332 | "Joe Jonas", 333 | "John Grant", 334 | "Járntjaldið", 335 | "Jóhann Jónsson", 336 | "Jóhann Sigurjónsson", 337 | "JóiPé og Króli", 338 | "Jól", 339 | "Jólakötturinn", 340 | "Jón Gunnar Bernburg", 341 | "Jón lærði Guðmundsson", 342 | "Jón Örn Loðmfjörð", 343 | "Jósef", 344 | "Jökulgarður", 345 | "Kalda stríðið", 346 | "Kapalsjónvarp", 347 | "Kapetingar", 348 | "Karl Ágúst Úlfsson", 349 | "Katla", 350 | "Katrín Jakobsdóttir", 351 | "Kembur", 352 | "Kennsl", 353 | "Kings Canyon-þjóðgarðurinn", 354 | "Kjarnorka", 355 | "Kjósarsýsla", 356 | "Kjörís", 357 | "Klassíski tíminn", 358 | "Kleina", 359 | "Klofning", 360 | "Knattspyrna", 361 | "Knattspyrna á Íslandi", 362 | "Knattspyrnufélag Fjallabyggðar", 363 | "Knattspyrnufélagið Víkingur", 364 | "Kok", 365 | "Kol", 366 | "Kolsýra", 367 | "Koltrefjar", 368 | "Kristján 1.", 369 | "Kristján 2.", 370 | "Kristján 3.", 371 | "Kristján 4.", 372 | "Kristján 5.", 373 | "Kristján 6.", 374 | "Kristján 7.", 375 | "Krossblómaætt", 376 | "Kvensöðull", 377 | "Kvikmynd", 378 | "Kynjalyf", 379 | "Kænugarður", 380 | "Kókaín", 381 | "Kósakkar", 382 | "Körfuknattleikur", 383 | "Landgrunn", 384 | "Landshöfðingi", 385 | "Laufskógar", 386 | "Leikbók", 387 | "Lettland", 388 | "Lissabon-sáttmálinn", 389 | "Listamannadeilan", 390 | "Listi yfir erlendar ferðabækur um Ísland", 391 | "Listi yfir heimspekinga", 392 | "Listi yfir hnúta", 393 | "Listi yfir leiðarstjörnur", 394 | "Listi yfir morð á Íslandi frá 1970–1999", 395 | "Listi yfir páfa", 396 | "Listi yfir vegamálastjóra", 397 | "Listi yfir íslensk eiginnöfn karlmanna", 398 | "Listi yfir íslensk mannanöfn", 399 | "Listi yfir íslensk póstnúmer", 400 | "Listi yfir íslenska tónlistarmenn", 401 | "Listi yfir útvarpsstöðvar á Íslandi", 402 | "Little Rock", 403 | "Ljósmynd", 404 | "Ljótu hálfvitarnir", 405 | "Loftþrýstingur", 406 | "Loki", 407 | "London School of Economics", 408 | "Los Angeles", 409 | "Loðna gullmoldvarpa", 410 | "Lunga", 411 | "Látra-Björg", 412 | "Lén", 413 | "Lífeldsneyti", 414 | "Líftækni", 415 | "Lögskýringarleið", 416 | "MacBook Air", 417 | "Magnús Stephensen", 418 | "Mahatma Gandhi", 419 | "Manchester", 420 | "Mannanafnanefnd", 421 | "Marktækni", 422 | "Markús Árelíus", 423 | "Matteo Ricci", 424 | "Max Weber", 425 | "McGill-háskóli", 426 | "MediaWiki", 427 | "Melasmári", 428 | "Menntaskólinn við Hamrahlíð", 429 | "Menntun", 430 | "Mið-Afríkulýðveldið", 431 | "Mánuður", 432 | "Mæðradagurinn", 433 | "Möttulstrókur", 434 | "Möttulstrókurinn undir Íslandi", 435 | "Múmínálfarnir", 436 | "Mýrarrauði", 437 | "Mýri", 438 | "Napóleon Bónaparte", 439 | "Negull", 440 | "Norður-Maríanaeyjar", 441 | "Nykur", 442 | "Nígeríska karlalandsliðið í knattspyrnu", 443 | "Nýlenduveldi", 444 | "Nýnorska", 445 | "Oddur Gottskálksson", 446 | "Opinn hugbúnaður", 447 | "Oprah Winfrey", 448 | "Orrustan við Dybbøl", 449 | "Otto Wathne", 450 | "Paolo Macchiarini", 451 | "Patreksfjörður", 452 | "Pennsylvanía", 453 | "Philippe Pétain", 454 | "Pinus johannis", 455 | "Plastbarkamálið", 456 | "Pressa", 457 | "Purpuri", 458 | "Páll Bergþórsson", 459 | "Pétur Halldórsson", 460 | "Pétur Haraldsson Blöndal", 461 | "Qalqilya", 462 | "Ragnhildur Gísladóttir", 463 | "Raunvextir", 464 | "Reykjavík", 465 | "Runólfur Ágústsson", 466 | "Rás 1", 467 | "Ríkisútvarpið", 468 | "Rím", 469 | "Ródesía", 470 | "Rómeó og Júlía", 471 | "Rökfræðileg raunhyggja", 472 | "Rúnar Kristinsson", 473 | "Sagnfræði", 474 | "Saltsýra", 475 | "Salvör", 476 | "Samrás", 477 | "Samskipadeild karla í knattspyrnu 1992", 478 | "Sanitas", 479 | "Sanngjörn afnot", 480 | "Savojaættin", 481 | "Senegal", 482 | "Seychelles-eyjar", 483 | "Shijiazhuang", 484 | "Sif Sigmarsdóttir", 485 | "Sigríður Hagalín Björnsdóttir", 486 | "Sigrún Edda Björnsdóttir", 487 | "Sigurður Geirdal", 488 | "Sigurður málari", 489 | "Sigð", 490 | "Sjáaldur", 491 | "Sjálfstæði nýlendnanna", 492 | "Sjávarspendýr", 493 | "Sjóvá-Almennra deild karla í knattspyrnu 1995", 494 | "Sjóvár-Almennra deild karla í knattspyrnu 1996", 495 | "Sjúkraflutningamaður", 496 | "Skapahár", 497 | "Skrifstofustjóri Alþingis", 498 | "Skriðjökull", 499 | "Skyndihjálp", 500 | "Skytturnar þrjár", 501 | "Skálmöld", 502 | "Skötuselur", 503 | "Skúli Magnússon", 504 | "Slot", 505 | "Sprengidagur", 506 | "Spænska", 507 | "Staðaraðferð", 508 | "Stiklusteinabrú", 509 | "Stiklutexti", 510 | "Stríð Prússlands og Austurríkis", 511 | "Stuðlaberg", 512 | "Stóuspeki", 513 | "Stöðurafmagn", 514 | "Stöðuvatn", 515 | "Summarfestivalurin", 516 | "Suzhou", 517 | "Suður-Kaliforníuháskóli", 518 | "Suður-Þingeyjarsýsla", 519 | "Suðurnesjabær", 520 | "Suðvestur-England", 521 | "Svartþröstur", 522 | "Sveitarfélagið Vogar", 523 | "Sálin hans Jóns míns", 524 | "Sæmundur fróði Sigfússon", 525 | "Sódavatn", 526 | "Sókrates", 527 | "Söngvari", 528 | "Súlú", 529 | "Súrefnismettunarmæling", 530 | "Taylor Lautner", 531 | "Taíland", 532 | "Tegund", 533 | "Thymus", 534 | "Tilvistarstefna", 535 | "Tjara", 536 | "Tjóðrun", 537 | "Torfbær", 538 | "Trans Ísland", 539 | "Trópídeild karla í knattspyrnu 1994", 540 | "Tæring", 541 | "Tíbeska hásléttan", 542 | "Tölva", 543 | "Túaregar", 544 | "Túnis", 545 | "USC Annenberg School for Communication", 546 | "Umferðarljós", 547 | "Upphafsstafaheiti", 548 | "Upplýsingin", 549 | "Urumqi", 550 | "Urðir", 551 | "User:Sneeuwschaap", 552 | "Utah", 553 | "Vagn", 554 | "Vegabréf", 555 | "Vegtollur", 556 | "Verðtrygging", 557 | "Vesturbær Reykjavíkur", 558 | "Vinstrihreyfingin – grænt framboð", 559 | "Virkisjökull", 560 | "Virðisaukaskattur", 561 | "Viðtengingarháttur", 562 | "Vísnabók Guðbrands", 563 | "Walter Cronkite", 564 | "Waltzing Matilda", 565 | "Web Content Accessibility Guidelines", 566 | "Wikimedia Commons", 567 | "Yasmina Reza", 568 | "Zug", 569 | "Áburðarverksmiðjan í Gufunesi", 570 | "Áhrifavaldur", 571 | "Árnes", 572 | "Árnessýsla", 573 | "Árni Björnsson", 574 | "Ástralía", 575 | "Ísafjarðardjúp", 576 | "Íslamska ríkið", 577 | "Íslandspóstur", 578 | "Íslenska", 579 | "Íslenska stafrófið", 580 | "Íslensku tónlistarverðlaunin 2008", 581 | "Ítalía", 582 | "Íþróttafélagið Huginn", 583 | "Íþróttafélagið Þór Akureyri", 584 | "Ólífa", 585 | "Ólöf Sigurðardóttir frá Hlöðum", 586 | "Ómar Ingi Magnússon", 587 | "Úkraína", 588 | "Útvarp Saga", 589 | "Útvarpsstjóri", 590 | "Þjóðhöfðingjar Danmerkur", 591 | "Þorskastríðin", 592 | "Þrjár stoðir Evrópusambandsins", 593 | "Þróunarlíffræði", 594 | "Þátttaka Íslands á Ólympíuleikum", 595 | "Þæfing", 596 | "Þíðjökull", 597 | "Þóra Arnórsdóttir" 598 | ] 599 | } -------------------------------------------------------------------------------- /words/wikipedia/la_wikipedia.json: -------------------------------------------------------------------------------- 1 | { 2 | "words": [ 3 | "Carolus Puigdemont", 4 | "Maria Antonia Austriaca", 5 | "Impetus Lutetiae Novembri 2015 facti", 6 | "Lingua Latina", 7 | "Scrotum", 8 | "2023", 9 | "Cursus eventorum quae ad Bellum Civile Americanum adduxerunt", 10 | "Eintracht Frankfurt", 11 | "Henricus Kissinger", 12 | "Iuris consultus", 13 | "Vici", 14 | "Abū Firās al-Ḥamdānī", 15 | "Aegidius", 16 | "Andorra", 17 | "Bracara Augusta", 18 | "Canno", 19 | "Carthago Nova", 20 | "Chlodovechus I", 21 | "Commentarii de Inepto Puero", 22 | "Cultura Minoa", 23 | "Durocortorum", 24 | "Gregorius Magnus", 25 | "Haroldus II", 26 | "Harrison Ford", 27 | "Hyperaldosteronismus", 28 | "Idriss Déby", 29 | "Ientaculum", 30 | "Ioanna Moreau", 31 | "Ioannes Stoltenberg", 32 | "Iosephus Conrad", 33 | "Kemi", 34 | "Latomismus", 35 | "Lingua Francogallica", 36 | "Lingua Nordica antiqua", 37 | "Lucius Iunius Brutus", 38 | "Lucus Augusti", 39 | "Mens", 40 | "Molière", 41 | "Novum Eboracum", 42 | "Opus fictile", 43 | "Orestes", 44 | "Piper nigrum", 45 | "Protomartyres Romani", 46 | "Receptorium NMDA", 47 | "Ricardus Stallman", 48 | "Rigordus", 49 | "Rodica-Michaela Stănoiu", 50 | "Simiiformes", 51 | "Status Pontificius", 52 | "Tau", 53 | "1017 Jacqueline", 54 | "14 Iunii", 55 | "1531", 56 | "1777", 57 | "1815", 58 | "1912", 59 | "1930", 60 | "1941", 61 | "1945", 62 | "2007", 63 | "2 Decembris", 64 | "7596 Yumi", 65 | "8142 Zolotov", 66 | "8 Augusti", 67 | "A", 68 | "Academia Scientiarum Russica", 69 | "Acis Vallis Viridis", 70 | "Adolphus Hitler", 71 | "Aelius Theon", 72 | "Aeneis", 73 | "Aenus", 74 | "Aeschines", 75 | "Ager Uvadensis", 76 | "Ager Vaticanus", 77 | "Agrapha", 78 | "Alchemia", 79 | "Alcides De Gasperi", 80 | "Alexander Alechin", 81 | "Alexander Darling", 82 | "Alpha-synucleinum", 83 | "Altissima planities Tibetana", 84 | "Ambrosius", 85 | "Amstelodanum", 86 | "Angaria", 87 | "Anilingus", 88 | "Anna-Barbara Gerl-Falkovitz", 89 | "Anulorum Erus", 90 | "Architrenius", 91 | "Armenia", 92 | "Ars ingeniaria", 93 | "Asteroidea", 94 | "Atropatene", 95 | "Augusta Treverorum", 96 | "Augustinus", 97 | "Aulus Atilius A.f. Calatinus", 98 | "Aurelianus", 99 | "Baracus Obama", 100 | "Basilica cathedralis Sancti Dionysii", 101 | "Beda", 102 | "Belgium", 103 | "Bellonia", 104 | "Berquilla", 105 | "Bletisa", 106 | "Bootes", 107 | "Bowie", 108 | "Bruce Dickinson", 109 | "Buch am Wald", 110 | "Burgi", 111 | "Butuntum", 112 | "Cafea expressa", 113 | "Calendarium Gregorianum", 114 | "Calesium", 115 | "California", 116 | "Cambosia", 117 | "Canalis", 118 | "Canticum Canticorum", 119 | "Carceres secreti CIA", 120 | "Carelia", 121 | "Carolus Martellus", 122 | "Catalaunia", 123 | "Cathanesiensis", 124 | "Cechia", 125 | "Celticus", 126 | "Champey", 127 | "Charlie hebdo", 128 | "Cilicium", 129 | "Circuitus integratus", 130 | "Circulus Dupont", 131 | "Citroën C4", 132 | "Civitates Foederatae", 133 | "Civitates Foederatae Americae", 134 | "Civitatum Foederatarum Secretarius Civitatis", 135 | "Classis", 136 | "Classis Romana", 137 | "Claudiopolis", 138 | "Cleopatrae mors", 139 | "Cluniacum", 140 | "Codex Iustinianus", 141 | "Codex Theodosianus", 142 | "Colin Farrell", 143 | "Collegium Cardinalium", 144 | "Collegium Gulielmi et Mariae", 145 | "Collegium Romanum", 146 | "Computatrum", 147 | "Connecticuta", 148 | "Corpus Iuris Civilis", 149 | "Corpus iuris canonici", 150 | "Corpus iuris civilis", 151 | "Cowes", 152 | "Cracovia", 153 | "Creep", 154 | "Croneburgum", 155 | "Cruces et circuli", 156 | "Crux gammata", 157 | "Cthulhu", 158 | "Cyprus", 159 | "Datorum repositorium", 160 | "David Livingstone", 161 | "De flagrorum usu in re veneria", 162 | "December", 163 | "Der kleine Pauly", 164 | "Dies Iovis", 165 | "Dioecesis Patavina", 166 | "Diurnariorum ars", 167 | "Diversitas culturae", 168 | "Domus Sabaudiana", 169 | "Draco", 170 | "Drenthia", 171 | "Duala", 172 | "Duces civitatum Europaearum hodiernarum", 173 | "Duglassius Adams", 174 | "Ecclesia Iesu Christi Diebus Ultimis Sanctorum", 175 | "Electricitas statica", 176 | "Elisabetha Taylor", 177 | "Elisaeus", 178 | "Ella Fitzgerald", 179 | "Emerita Augusta", 180 | "Episcopus", 181 | "Erga omnes", 182 | "Ericus Fottorino", 183 | "Eugenius Franciscus Vidocq", 184 | "Eusebius", 185 | "Fada", 186 | "Farina", 187 | "Fatum Manifestum", 188 | "Ferdinandus Cortesius", 189 | "Flavus", 190 | "Flora Sinensis", 191 | "Forum Vetus", 192 | "Franciscus", 193 | "Fretum Magellanicum", 194 | "Fundus Animalium", 195 | "Gaius Plinius Secundus", 196 | "Gazella", 197 | "Geraldina Ferraro", 198 | "Globalizatio", 199 | "Gloria in excelsis", 200 | "Graece", 201 | "Grosne", 202 | "Guichainville", 203 | "Gulielmus von Humboldt", 204 | "Haedui", 205 | "Halbe Zijlstra", 206 | "Hannovera", 207 | "Harry Potter and the Goblet of Fire", 208 | "Harvaroddus", 209 | "Haskala", 210 | "Hasta el fin del mundo", 211 | "Henricus Gulielmus Kopf", 212 | "Herma", 213 | "Hester Lynch Piozzi", 214 | "Hieronymus Lalande", 215 | "Historia", 216 | "Historia Romana", 217 | "Historia rerum in partibus transmarinis gestarum", 218 | "Homininae", 219 | "Hominini", 220 | "Homo", 221 | "Horatii et Curiatii", 222 | "Humphredus Bogart", 223 | "Hymnus nationalis", 224 | "Hypertextus", 225 | "Ianuarii", 226 | "Immobile", 227 | "Imperator Sacuramati", 228 | "Imperatores Iaponiae", 229 | "Imperatrix Go-Sacuramati", 230 | "Imperium Romanum", 231 | "Imperium Romanum Occidentale", 232 | "Imperium Romanum Orientale", 233 | "Impi", 234 | "In girum imus nocte ecce et consumimur igni", 235 | "Inanna", 236 | "Inclavatura et exclavatura", 237 | "Index communium Nederlandiae", 238 | "Indonesia", 239 | "Infernus", 240 | "Infrastructura et superstructura", 241 | "Innocentius IV", 242 | "Inscriptio de sancto Clemente et Sisinio", 243 | "Ioanna Calment", 244 | "Ioannes Barbu", 245 | "Ioannes Lubbock", 246 | "Ioannes Michael Lounge", 247 | "Ioannes Poddubnyj", 248 | "Ioannes Renatus Constantinus Quoy", 249 | "Iosephus Biden", 250 | "Iota", 251 | "Isaac Rabin", 252 | "Isaias de Noronha", 253 | "Isala Hollandica", 254 | "Islandia", 255 | "Iudex", 256 | "Iura civitatum", 257 | "Iura minoritatum", 258 | "Iurisprudentia", 259 | "Ius", 260 | "Ius Romanum", 261 | "Ius civile", 262 | "Ius cogens", 263 | "Ius gentium", 264 | "Ius in rem", 265 | "Ius naturale", 266 | "Ius privatum", 267 | "Iustinianus I", 268 | "Jelle Zijlstra", 269 | "Jonchery", 270 | "Kanye West", 271 | "Kenethus Annakin", 272 | "Kiyomizu-dera", 273 | "Kyrgyzstania", 274 | "LZ 129 Hindenburg", 275 | "Legio XV Apollinaris", 276 | "Lex", 277 | "Lex militaris", 278 | "Liber", 279 | "Libera res publica Romana", 280 | "Lingua Atropatenica", 281 | "Lingua Hispanica", 282 | "Lingua Iaponica", 283 | "Lingua Palica", 284 | "Lingua Urdu", 285 | "Lingua Vietnamensis", 286 | "Linguae Lahnda", 287 | "Linguae Uto-Aztecae", 288 | "Liquor", 289 | "Luchianum", 290 | "Ludi Saeculares", 291 | "Ludovicus XVIII", 292 | "Mancunium", 293 | "Manifestationes Aegyptiae anni 2011", 294 | "Manzanum", 295 | "Marcus Hunter", 296 | "Martinica", 297 | "Materia medica", 298 | "Matterhorn", 299 | "Maximilianus Weber", 300 | "Maximimum", 301 | "Medicina succursoria", 302 | "Meland", 303 | "Menno Simons", 304 | "Mia Farrow", 305 | "Microsoft Windows", 306 | "Milovan Đilas", 307 | "Motus", 308 | "My", 309 | "Nancianum", 310 | "Naturalis historia", 311 | "Naufragium", 312 | "Navis", 313 | "Navis Nemorensis", 314 | "Neapolis", 315 | "Neutron", 316 | "Nicolaus Harvey", 317 | "Nicolaus Kuznecov", 318 | "Nissa", 319 | "NoHo", 320 | "Nova Hibernia", 321 | "Oceanus Germanicus", 322 | "Oliverus Sacks", 323 | "Omicron", 324 | "Onomatopoeia", 325 | "Oppidum", 326 | "Orbita", 327 | "Oxoniensis comitatus", 328 | "Oxycoccus", 329 | "Pafyumu", 330 | "Pagina prima", 331 | "Pan", 332 | "Pandectae", 333 | "Pelagius II", 334 | "Pendulum", 335 | "Penis hominis", 336 | "Petasus", 337 | "Petrus Lellouche", 338 | "Philolaus Crotoniensis", 339 | "Photographema", 340 | "Physalis peruviana", 341 | "Pitcairn Insulae", 342 | "Polynices", 343 | "Pons", 344 | "Positivismus logicus", 345 | "Possidius Calamensis", 346 | "Praesumptio iuris tantum", 347 | "Praga", 348 | "Primates", 349 | "Proelium", 350 | "Programma fontium apertorum", 351 | "Propulsorium", 352 | "Prudentius", 353 | "Psychologia", 354 | "Pulchritudo", 355 | "Purpura", 356 | "Q", 357 | "Reformatio", 358 | "Regio", 359 | "Res Publica Utriusque Nationis", 360 | "Rhenania Septentrionalis-Vestfalia", 361 | "Rho", 362 | "Ricardus David Precht", 363 | "Robertus Einstein", 364 | "Robertus Franciscus Kennedy", 365 | "Rogerius Scruton", 366 | "Rudolphus Höß", 367 | "Ruthenia Alba", 368 | "S", 369 | "Sabinus a Placentia", 370 | "Saeculum 18", 371 | "Saeculum 19", 372 | "Sally Margarita Field", 373 | "Salome", 374 | "Samara", 375 | "Samuel Beckett", 376 | "Sarah Kerrigan", 377 | "Schola Genevensis", 378 | "Sculptura", 379 | "Secundum bellum mundanum", 380 | "Semen", 381 | "Sociologia", 382 | "Somnium", 383 | "Sorbona", 384 | "Spartacus", 385 | "Speusippus", 386 | "Statio Terminalis Media Grandis", 387 | "Sublimis", 388 | "Sui iuris", 389 | "Suppositorium", 390 | "Surria", 391 | "Tavaux", 392 | "Technologia", 393 | "Terminator: Genisys", 394 | "Terrae australes antarcticaeque Francicae", 395 | "The Call of Cthulhu", 396 | "The Dark Knight", 397 | "The Third Man", 398 | "Theorema binomiale", 399 | "Thulay", 400 | "Tiberius", 401 | "Tibetum", 402 | "Tolonium", 403 | "Tournefeuille", 404 | "Transitus Urigae", 405 | "Tugium", 406 | "Tyrtarion", 407 | "UTC+02:00", 408 | "Ucraina", 409 | "Unio Sovietica", 410 | "United States Department of State", 411 | "Universitas Bonaëropolitana", 412 | "Universitas Californiae Meridianae", 413 | "Universitas Oldenburgensis", 414 | "Valdoie", 415 | "Vallis Dominorum et Vallis Comitum", 416 | "Vascus Gama", 417 | "Vera Crux", 418 | "Vexillum Finniae", 419 | "Vexillum Ucrainae", 420 | "Via lactea", 421 | "Vicifons", 422 | "Vicimedia Communia", 423 | "Vosiolum", 424 | "Vulgata Xystina-Clementina", 425 | "Vulva", 426 | "Woody Guthrie", 427 | "Xi", 428 | "YouTube", 429 | "Ypsilon", 430 | "Zingari", 431 | "theorema binomiale", 432 | "vicipaedia:pagina prima" 433 | ] 434 | } --------------------------------------------------------------------------------