]> git.kianting.info Git - clo/blob - src/libclo/index.ts
e5c2108442e2deae04678c9a11e261f70f6f5ed7
[clo] / src / libclo / index.ts
1 import {tkTree} from "../parser";
2 import {FontStyle, TextStyle, TextWeight, fontStyleTofont} from "../canva";
3 import * as fontkit from "fontkit";
4 import * as breakLines from "./breakLines";
5 const PDFDocument = require('pdfkit');
6 import * as fs from "fs";
7
8
9 /**
10 * TYPES
11 */
12
13 /**
14 * text direction
15 * LTR - left to right
16 * TTB - top to bottom
17 * etc.
18 */
19 export enum Direction{
20 LTR,
21 RTL,
22 TTB,
23 BTT,
24 }
25
26 /**
27 * Horizonal glue.
28 * - stretchFactor : the stretch factor in float
29 */
30 export interface HGlue{
31 stretchFactor: number
32 }
33
34 export interface BreakPoint{
35 original : BoxesItem,
36 newLined : BoxesItem
37 }
38
39 export type BoxesItem = HGlue | Box | BreakPoint | BoxesItem[] ;
40
41 /**
42 * frame box is a subclass of box
43 * - directionInsideLine : text direction inside a line
44 * - baselineskip : the distance between baselines in px
45 */
46 export interface FrameBox extends Box{
47 directionInsideLine : Direction,
48 baseLineskip : number | null,
49 }
50
51 export interface CharBox extends Box{
52 minX: number,
53 maxX: number,
54 minY: number,
55 maxY: number,
56
57 }
58
59 /**
60 * a basic Box
61 * - x : pt
62 * - y : pt
63 * - textStyle :
64 * - direction :
65 * - width : x_advance pt
66 * - content :
67 */
68 export interface Box{
69 x : number | null,
70 y : number | null,
71 textStyle : TextStyle | null,
72 direction : Direction,
73 width : number,
74 height : number,
75 content : string | Box[] | null,
76 }
77
78
79 /**
80 * DEFAULT CONST PART
81 */
82 export const A4_IN_PX = {"width" : 793.7,
83 "height" : 1122.5};
84
85 export const defaultTextStyle : TextStyle = {
86 family : "FreeSerif",
87 size : ptToPx(12),
88 textWeight : TextWeight.REGULAR,
89 fontStyle : FontStyle.ITALIC,
90 }
91
92 export const defaultFrameStyle : FrameBox = {
93 directionInsideLine : Direction.LTR,
94 direction : Direction.TTB,
95 baseLineskip : ptToPx(15),
96 textStyle : defaultTextStyle,
97 x : A4_IN_PX.width * 0.10 ,
98 y : A4_IN_PX.height * 0.10 ,
99 width : A4_IN_PX.width * 0.80 ,
100 height : A4_IN_PX.height * 0.80 ,
101 content : null,
102 };
103
104 /**
105 * definition for cjk scripts
106 * - Hani : Han Character
107 * - Hang : Hangul
108 * - Bopo : Bopomofo
109 * - Kana : Katakana
110 * - Hira : Hiragana
111 */
112 export const cjkvBlocksInRegex = ["Hani", "Hang", "Bopo", "Kana", "Hira"];
113
114 export const cjkvRegexPattern = new RegExp("((?:" +
115 cjkvBlocksInRegex.map((x)=>"\\p{Script_Extensions="+x+"}").join("|") + ")+)", "gu");
116 /**
117 * FUNCTION PART
118 */
119 /**
120 * convert from ptToPx
121 * @param pt pt size value
122 * @returns the corresponding px value
123 */
124 export function ptToPx(pt : number) : number{
125 return pt * 4.0 / 3.0;
126 }
127
128
129
130 /**
131 * REGISTER PART
132 */
133
134 /**
135 * convert '\n\n' to newline command ["nl"]
136 * @param arr the input `tkTree`
137 * @param clo the `Clo` object
138 * @returns the input tktree
139 */
140 export function twoReturnsToNewline(arr : tkTree, clo : Clo): tkTree{
141 var middle : tkTree = [];
142
143 for (let i = 0; i < arr.length; i++) {
144 var item = arr[i];
145 if (!Array.isArray(item)){
146 middle = middle.concat(item.split(/(\n\n)/g));
147 }
148 else{
149 middle.push(item);
150 }
151 }
152
153 var result : tkTree = [];
154 for (let j = 0; j < middle.length; j++){
155 var item = middle[j];
156 if (!Array.isArray(item) && item == "\n\n"){
157 result.push(["nl"]); // push a newline command to the result `tkTree`
158 }
159 else{
160 result.push(middle[j]);
161 }
162 }
163
164 return result;
165 }
166
167 /**
168 * split CJKV and non-CJKV
169 *
170 * @param arr : input tkTree
171 * @returns a splitted tkTree (by CJK and NonCJK)
172 * - Examples:
173 * ```
174 * [`many臺中daylight`] => [`many`, `臺中`, `dahylight`]
175 * ```
176 */
177 export function splitCJKV(arr : tkTree, clo : Clo): tkTree{
178 var result : tkTree = [];
179 for (let i = 0; i < arr.length; i++) {
180 var item = arr[i];
181 if (!Array.isArray(item)){
182 result = result.concat(item.split(cjkvRegexPattern));
183 }
184 else{
185 result.push(item);
186 }
187 }
188
189 return result;
190 }
191
192 /**
193 * hyphenation for a clo document
194 * @param arr the array for a `tkTree`
195 * @param clo the Clo object
196 */
197 export function hyphenForClo(arr : tkTree, clo : Clo): tkTree{
198 let hyphenLanguage : string = clo.attrs["hyphenLanguage"];
199 let res = hyphenTkTree(arr, hyphenLanguage);
200 return res;
201
202 }
203
204 /**
205 * convert spaces to Breakpoint
206 * \s+ => ["bp" [\s+] ""]
207 * @param arr the tkTree input text stream
208 * @param clo the Clo object
209 * @returns the converted object
210 */
211 export function spacesToBreakpoint(arr : tkTree, clo : Clo) : tkTree{
212 let spacePattern = /^([ \t]+)$/g;
213 var result : tkTree = [];
214 for (let i = 0; i < arr.length; i++){
215 var item = arr[i];
216 if (!Array.isArray(item) && item.match(spacePattern)){
217 // push a breakpoint command to the result `tkTree`
218 result.push([ 'bp', [["hglue", "0.1"], item] , "" ]);
219 }
220 else{
221 result.push(item);
222 }
223 }
224
225 return result;
226 }
227
228 /**
229 * remove all the `` (empty string) in the arr
230 * @param arr the tkTree to be filtered
231 * @param clo the Clo file
232 */
233 export function filterEmptyString(arr : tkTree, clo : Clo) : tkTree{
234 if (Array.isArray(arr)){
235 arr.filter((x)=>{return x != ``;});
236 }
237
238 return arr;
239 }
240
241
242 /**
243 * OTHER FUNCTIONS
244 */
245
246 /**
247 * hyphenate for a tkTree
248 * - hyphenation => ["bp", "", "-"]
249 * @param arr the tkTree array
250 * @param lang ISO 639 code for the language
251 */
252 export function hyphenTkTree(arr : tkTree, lang: string) : tkTree{
253 // import corresponding hyphen language data and function
254 let hyphen = require("hyphen/"+lang);
255
256 let result :tkTree[] = [];
257 for (let i = 0; i < arr.length; i++) {
258 let element = arr[i];
259 let splitter = "分"; // a CJKV
260 if (!Array.isArray(element)){
261 let hyphenatedElement : string = hyphen.hyphenateSync(element, {hyphenChar :splitter});
262 let hyphenatedSplitted : tkTree = hyphenatedElement.split(splitter);
263 var newSplitted : tkTree = [];
264 for (var j=0; j<hyphenatedSplitted.length-1;j++){
265 newSplitted.push(hyphenatedSplitted[j]);
266 // "bp" for breakpoint
267 newSplitted.push(["bp", "", "-"]); //insert a breakable point (bp) mark
268 }
269 newSplitted.push(hyphenatedSplitted[hyphenatedSplitted.length-1]);
270
271 result = result.concat(newSplitted);
272
273 }else{
274 result.push(element);
275 }
276
277 }
278
279 return result;
280 }
281
282 /**
283 * calculate the text width and Height with a given `TextStyle`
284 * @param preprocessed
285 * @param defaultFontStyle
286 */
287 export async function calculateTextWidthHeight(element : tkTree, style : TextStyle): Promise<BoxesItem[]> {
288 var res = [];
289
290 for (var i=0; i<element.length; i++){
291 res.push(await calculateTextWidthHeightAux(element[i], style));
292 }
293
294 res = res.flat();
295
296 return res;
297 }
298
299
300 /**
301 * calculate the text width and Height with a given `TextStyle`
302 * @param preprocessed
303 * @param defaultFontStyle
304 */
305 export async function calculateTextWidthHeightAux(element : tkTree, style : TextStyle): Promise<BoxesItem> {
306 var result : BoxesItem = [];
307
308
309
310 let fontPair = fontStyleTofont(style);
311 if (fontPair.path.match(/\.ttc$/)){
312 var font = await fontkit.openSync(fontPair.path, fontPair.psName);
313 }
314 else{
315 var font = await fontkit.openSync(fontPair.path);
316 }
317 if (!Array.isArray(element)){
318 var run = font.layout(element, undefined, undefined, undefined, "ltr");
319
320
321
322 for (var j=0;j<run.glyphs.length;j++){
323 let runGlyphsItem = run.glyphs[j];
324
325
326 let item : CharBox = {
327 x : null,
328 y : null,
329 textStyle : style,
330 direction : Direction.LTR,
331 width : (runGlyphsItem.advanceWidth)*(style.size)/1000 * 0.75, // in pt
332 height : (runGlyphsItem.bbox.maxY - runGlyphsItem.bbox.minY)*(style.size)/1000 * 0.75, // in pt
333 content : element[j],
334 minX : runGlyphsItem.bbox.minX,
335 maxX : runGlyphsItem.bbox.maxX,
336 minY : runGlyphsItem.bbox.minY,
337 maxY : runGlyphsItem.bbox.maxY
338 }
339
340 result.push(item);
341
342 }
343 return result;
344
345
346
347
348 }else if(element[0] == "bp"){
349
350 var beforeNewLine = await calculateTextWidthHeightAux(element[1], style);
351 if (Array.isArray(beforeNewLine)){
352 beforeNewLine = beforeNewLine.flat();
353 }
354
355 let afterNewLine = await calculateTextWidthHeightAux(element[2], style);
356 if (Array.isArray(afterNewLine)){
357 afterNewLine = afterNewLine.flat();
358 }
359
360 let breakPointNode : BreakPoint = {
361 original : beforeNewLine,
362 newLined : afterNewLine,
363 }
364
365 return breakPointNode;
366 }else if(element[0] == "hglue" && !Array.isArray(element[1])){
367 let hGlue : HGlue = {stretchFactor : parseFloat(element[1])}
368 return hGlue;
369 }
370 else{
371 return calculateTextWidthHeight(element, style);
372 }
373 }
374
375
376
377
378 /**
379 * whole document-representing class
380 */
381 export class Clo{
382 /** storing the text string into the main frame */
383 mainStream : Array<string>;
384 /** array of preprocessor functions to preprocess the `mainStream` */
385 preprocessors : Array<Function>;
386 /** the attributes for the Clo */
387 attrs: {[index: string]:any} ; // a4 size(x,y)
388
389
390 constructor(){
391 this.preprocessors = [];
392 this.mainStream = [];
393 this.attrs = {
394 "page" : A4_IN_PX, // default for a4. in px of [x, y]
395 "defaultFrameStyle" : defaultFrameStyle, // defaultFrameStyle
396 "hyphenLanguage" : 'en' // hyphenated in the language (in ISO 639)
397 };
398
399
400
401 // register the precessor functions
402 this.preprocessorRegister(splitCJKV);
403 this.preprocessorRegister(hyphenForClo);
404 this.preprocessorRegister(twoReturnsToNewline);
405 this.preprocessorRegister(spacesToBreakpoint);
406 this.preprocessorRegister(filterEmptyString);
407 }
408
409 public setAttr(attr : string, val : any):void{
410 Object.assign(this.attrs, attr, val);
411 }
412
413 public getAttr(attr:string) : any{
414 if (Object.keys(this.attrs).length === 0){
415 return this.attrs[attr];
416 }else{
417 return undefined;
418 }
419
420 }
421
422 /**
423 * register a function of preprocessor
424 * @param f a function
425 */
426 public preprocessorRegister(f : Function){
427 this.preprocessors.push(f);
428 }
429
430 public async generatePdf(){
431 // preprocessed
432 var preprocessed = this.mainStream;
433 for (var i = 0; i<this.preprocessors.length; i++){
434 preprocessed = this.preprocessors[i](preprocessed, this);
435 }
436 // generate the width and height of the stream
437
438 let defaultFontStyle : TextStyle = this.attrs.defaultFrameStyle.textStyle;
439 let a = await calculateTextWidthHeight(preprocessed, defaultFontStyle);
440
441 let breakLineAlgorithms = new breakLines.BreakLineAlgorithm();
442 // TODO
443 //console.log(breakLineAlgorithms.totalCost(a,70));
444 let segmentedNodes = breakLineAlgorithms.segmentedNodes(a, this.attrs.defaultFrameStyle.width);
445
446 let segmentedNodesToBox =
447 this.segmentedNodesToFrameBox(segmentedNodes, <FrameBox>this.attrs.defaultFrameStyle);
448
449
450
451 let boxesFixed = this.fixenBoxesPosition(segmentedNodesToBox);
452
453
454
455
456 // generate pdf7
457 const doc = new PDFDocument({size: 'A4'});
458 doc.pipe(fs.createWriteStream('output.pdf'));
459 this.grid(doc);
460
461 await this.putText(doc, boxesFixed);
462 // putChar
463 doc.end();
464
465 }
466
467 async putText(doc : PDFKit.PDFDocument, box : Box): Promise<PDFKit.PDFDocument>{
468
469 if (box.textStyle !== null){
470 let fontInfo = fontStyleTofont(box.textStyle);
471
472 if (fontInfo.path.match(/\.ttc$/g)){
473 doc
474 .font(fontInfo.path, fontInfo.psName)
475 .fontSize(box.textStyle.size * 0.75);}
476 else{
477 doc
478 .font(fontInfo.path)
479 .fontSize(box.textStyle.size * 0.75); // 0.75 must added!
480 }
481
482 if (box.textStyle.color !== undefined){
483 doc.fill(box.textStyle.color);
484 }
485
486 if (Array.isArray(box.content)){
487 for (var k=0; k<box.content.length; k++){
488
489 doc = await this.putText(doc, box.content[k]);
490 }
491 }else if (box.content !== null){
492 await doc.text(box.content,
493 (box.x!==null? box.x: undefined),
494 (box.y!==null? box.y: undefined));
495 }
496
497 }
498 return doc;
499 };
500
501
502
503 private grid(doc: any) {
504 for (var j = 0; j < A4_IN_PX.width; j += 5) {
505 if (j % 50 == 0) {
506 doc.save().fill('#000000')
507 .fontSize(8).text(j.toString(), j*0.75, 50);
508
509 doc
510 .save()
511 .lineWidth(0.4)
512 .strokeColor("#dddddd")
513 .moveTo(j*0.75, 0)
514 .lineTo(j*0.75, 1000)
515 .stroke();
516 }
517
518 doc
519 .save()
520 .lineWidth(0.2)
521 .strokeColor("#dddddd")
522 .moveTo(j*0.75, 0)
523 .lineTo(j*0.75, 1000)
524 .stroke();
525 }
526
527 for (var i = 0; i < 1050; i += 5) {
528 if (i % 50 == 0) {
529 doc.save()
530 .fontSize(8).text(i.toString(), 50, i*0.75);
531
532 doc
533 .save()
534 .lineWidth(0.4)
535 .strokeColor("#bbbbbb")
536 .moveTo(0, i*0.75)
537 .lineTo(1000, i*0.75)
538 .stroke();
539 }
540 doc
541 .save()
542 .lineWidth(0.2)
543 .strokeColor("#bbbbbb")
544 .moveTo(0, i*0.75)
545 .lineTo(1000, i*0.75)
546 .stroke();
547 }
548 doc
549 .save()
550 .moveTo(0, 200)
551 .lineTo(1000, 200)
552 .fill('#FF3300');
553 }
554
555 /**
556 * make all the nest boxes's position fixed
557 * @param box the main boxes
558 * @returns the fixed boxes
559 */
560 fixenBoxesPosition(box : Box) : Box{
561 var currX : number = (box.x!==null?box.x:0); // current x
562 var currY : number =(box.y!==null?box.y:0); // current y
563 if (Array.isArray(box.content)){
564 for (var i=0; i<box.content.length; i++){
565 if (box.direction == Direction.LTR){
566 box.content[i].x = currX;
567 box.content[i].y = currY;
568 let elementWidth = box.content[i].width;
569 if(elementWidth !== null){
570 currX += elementWidth;
571 }
572
573 }
574 if (box.direction == Direction.TTB){
575 box.content[i].x = currX;
576 box.content[i].y = currY;
577 let elementHeight = box.content[i].height;
578 if(elementHeight !== null){
579 currY += elementHeight;
580 }
581
582 }
583
584
585 box.content[i] = this.fixenBoxesPosition(box.content[i]);
586 }
587 }
588
589 return box;
590 }
591
592 /**
593 * input a `segmentedNodes` and a layed `frame`, return a big `Box` that nodes is put in.
594 * @param segmentedNodes the segmentnodes to be input
595 * @param frame the frame to be layed out.
596 * @returns the big `Box`.
597 */
598 segmentedNodesToFrameBox(segmentedNodes : BoxesItem[][], frame : FrameBox) : Box{
599 let baseLineskip = frame.baseLineskip;
600 let boxArrayEmpty : Box[] = [];
601 let bigBox : Box = {
602 x : (frame.x !==null? frame.x * 0.75 : null),
603 y : (frame.y !==null? frame.y * 0.75 : null),
604 textStyle : frame.textStyle,
605 direction : frame.direction,
606 width : frame.width,
607 height :frame.height,
608 content : boxArrayEmpty,
609 }
610
611 var bigBoxContent : Box[] = boxArrayEmpty;
612
613 let segmentedNodesFixed = segmentedNodes.map((x)=>this.removeBreakPoints
614 (x).flat());
615 let segmentedNodeUnglue = segmentedNodesFixed.map((x)=>this.removeGlue(x, frame).flat());
616
617 for (var i=0; i<segmentedNodeUnglue.length; i++){
618 var currentLineSkip = baseLineskip;
619 var glyphMaxHeight = this.getGlyphMaxHeight(segmentedNodesFixed[i]);
620 if (currentLineSkip === null || glyphMaxHeight >currentLineSkip ){
621 currentLineSkip = glyphMaxHeight;
622 }
623
624 var currentLineBox : Box = {
625 x : null,
626 y : null,
627 textStyle : defaultTextStyle,
628 direction : frame.directionInsideLine,
629 width : frame.width,
630 height : currentLineSkip,
631 content : <Box[]>segmentedNodeUnglue[i],
632 }
633
634 bigBoxContent.push(currentLineBox);
635
636 }
637
638 bigBox.content = bigBoxContent;
639
640 return bigBox;
641 }
642
643 /**
644 * get the max height of the glyph`[a, b, c]`
645 * @param nodeLine the node line [a, b, c, ...]
646 * @returns
647 */
648 getGlyphMaxHeight(nodeLine : BoxesItem[]) : number{
649 let segmentedNodeLineHeight = nodeLine.map((x : BoxesItem)=>{if ("height" in x && x.height > 0.0){return x.height}else{return 0.0}});
650 let maxHeight = Math.max(...segmentedNodeLineHeight);
651 return maxHeight;
652 }
653
654 removeGlue(nodeLine : BoxesItem[], frame : FrameBox) : BoxesItem[]{
655 let breakLineAlgorithms = new breakLines.BreakLineAlgorithm();
656 let glueRemoved = nodeLine.filter((x)=>!breakLineAlgorithms.isHGlue(x));
657 let onlyGlue = nodeLine.filter((x)=>breakLineAlgorithms.isHGlue(x));
658 let sumStretchFactor = onlyGlue.map((x)=>{if("stretchFactor" in x){ return x.stretchFactor} else{return 0;}})
659 .reduce((acc, cur)=>acc+cur , 0);
660
661 let glueRemovedWidth = glueRemoved.map((x)=>{if("width" in x){ return x.width} else{return 0;}})
662 .reduce((acc, cur)=>acc+cur , 0);
663 let offset = frame.width * 0.75 - glueRemovedWidth;
664 var res = [];
665 for (var i=0; i<nodeLine.length; i++){
666 var ele = nodeLine[i];
667 if (breakLineAlgorithms.isHGlue(ele)){
668 let tmp : Box = {
669 x : null,
670 y : null,
671 textStyle : null,
672 direction : frame.directionInsideLine,
673 //width : 0, // ragged
674 width : ele.stretchFactor / sumStretchFactor * offset,
675 height : 0,
676 content : "",
677
678 }
679
680 res.push(tmp);
681 }else{
682 res.push(ele);
683 }
684 }
685
686 return res;
687 }
688
689 /**
690 * remove breakpoints
691 * @param boxitemline boxitem in a line with a breakpoint
692 * @returns boxitemline with break points removed
693 */
694 removeBreakPoints(boxitemline : BoxesItem[]) : BoxesItem[]{
695 var res : BoxesItem[] = [];
696 let breakLineAlgorithms = new breakLines.BreakLineAlgorithm();
697
698 for (var i = 0; i<boxitemline.length; i++){
699 let ele = boxitemline[i];
700 if (breakLineAlgorithms.isBreakPoint(ele)){
701 if (i == boxitemline.length-1){
702 res.push(ele.newLined);
703 }else{
704 res.push(ele.original);
705 }
706 }else{
707 res.push(ele);
708 }
709 }
710
711 return res;
712 }
713
714
715 }
716
717
718 /*
719 export let a = new Clo();
720 export default a; */