UNPKG

@mattuy/text-frame

Version:

a javascript tool to split long text into pages, with typesetting prohibition processed and unicode full support. 一个将长文本分成若干页的js工具,处理排版禁则,支持Unicode

416 lines (408 loc) 18.2 kB
/*! ***************************************************************************** Copyright (c) Microsoft Corporation. Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted. THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. ***************************************************************************** */ var __assign = function() { __assign = Object.assign || function __assign(t) { for (var s, i = 1, n = arguments.length; i < n; i++) { s = arguments[i]; for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p)) t[p] = s[p]; } return t; }; return __assign.apply(this, arguments); }; function __rest(s, e) { var t = {}; for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p) && e.indexOf(p) < 0) t[p] = s[p]; if (s != null && typeof Object.getOwnPropertySymbols === "function") for (var i = 0, p = Object.getOwnPropertySymbols(s); i < p.length; i++) { if (e.indexOf(p[i]) < 0 && Object.prototype.propertyIsEnumerable.call(s, p[i])) t[p[i]] = s[p[i]]; } return t; } var TextAlign; (function (TextAlign) { TextAlign["center"] = "center"; TextAlign["start"] = "start"; TextAlign["end"] = "end"; TextAlign["left"] = "left"; TextAlign["right"] = "right"; TextAlign["justify"] = "justify"; })(TextAlign || (TextAlign = {})); var defaultOptionBase = { color: '#000000', fontFamily: 'serif', fontWeight: 'normal', fontSize: 16, rtl: false, margin: 0, marginCollapse: true, noHeadMargin: false, textIndent: 0, textAlign: 'justify', textAlignLast: 'start' }, defaultOptions = __assign(__assign({}, defaultOptionBase), { viewWidth: 300, viewHeight: 150, lineStartProhibitedMarks: "\u3001,\uFF0C.\uFF0E\u3002:\uFF1A;\uFF1B!\uFF01?\uFF1F'\"\u300D\u300F\u201D\u2019)]}\uFF09\u3011\u3017\u3015\uFF3D\uFF5D\u300B\u3009\u2013~\uFF5E\u2014\u00B7\uFF0E\u2027\u2022\u30FB/\uFF0F ", lineEndProhibitedMarks: "\u300C\u300E\u201C\u2018([{\uFF08\u3010\u3016\u3014\uFF3B\u3014\u300A\u3008/\uFF0F", unbreakableRule: /^(──|……|[\w\d]+)$/, fragments: null }); function resolveBaseOptions(options) { // we don't cache devicePixelRatio globally, since it may change var dpi = devicePixelRatio || 1; var resolvedOptions = __assign(__assign({}, defaultOptionBase), (options || Object())); resolvedOptions.textIndent = (resolvedOptions.textIndent | 0) * dpi; if (resolvedOptions.fontSize > 0) { resolvedOptions.fontSize *= dpi; } else { resolvedOptions.fontSize = defaultOptions.fontSize * dpi; } if (resolvedOptions.lineHeight > 0) { resolvedOptions.lineHeight *= dpi; } else { resolvedOptions.lineHeight = resolvedOptions.fontSize * 1.5; } if (!(resolvedOptions.textAlign in TextAlign)) { resolvedOptions.textAlign = TextAlign.start; } if (typeof resolvedOptions.margin === 'object' && resolvedOptions !== null) { for (var _i = 0, _a = ['left', 'right', 'top', 'bottom']; _i < _a.length; _i++) { var s = _a[_i]; resolvedOptions.margin[s] = (resolvedOptions.margin[s] | 0) * dpi; } } else { var margin = resolvedOptions.margin * dpi; resolvedOptions.margin = { left: margin, right: margin, top: margin, bottom: margin }; } return resolvedOptions; } function resolveOptions(options) { var dpi = devicePixelRatio, resolvedOptions = __assign(__assign({}, defaultOptions), resolveBaseOptions(options)); resolvedOptions.canvasWidth = options.canvasWidth || resolvedOptions.viewWidth * dpi; resolvedOptions.canvasHeight = options.canvasHeight || resolvedOptions.viewHeight * dpi; return resolvedOptions; } function resolveFragmentOptions(slideOps, fragOps) { // margin is not inherited from frame options var _ = slideOps.margin, fallback = __rest(slideOps, ["margin"]); // for ignored config, fallback to global option, then fallback to default option var resolvedOptions = __assign(__assign({}, fallback), fragOps); resolvedOptions.text = resolvedOptions.text || ''; resolvedOptions.text = resolvedOptions.text.replace('\r\n', '\n').replace('\r', '\n'); resolvedOptions = resolveBaseOptions(resolvedOptions); if (resolvedOptions.trim) { resolvedOptions.text = resolvedOptions.text.trim(); } return resolvedOptions; } var ctx = document.createElement('canvas').getContext('2d'); function computeTextFrames(options) { var ops = resolveOptions(options || {}), frames = [], frameMargin = ops.margin, maxFrameX = ops.canvasWidth - 1 - frameMargin.right, maxFrameY = ops.canvasHeight - 1 - frameMargin.bottom; var cursor = { x: 0, y: 0 }, textIndex = 0, maxLineX = 0, remainingMargin = 0, previousMarginCollapse = false, currFragment, currentText, currentLine, currentFrame; /** * get next char. * JavaScript encode string with UTF-16, where one unicode code point may be * encoded with two chars(such as emoji), so we need to deal with that case. * Refrence(English): @see https://en.wikipedia.org/wiki/UTF-16 * Refrence(Chinese): @see https://zh.wikipedia.org/wiki/UTF-16 */ function nextChar() { if (!currentText) { return ''; } var codePoint = currentText.codePointAt(textIndex); if (codePoint === undefined) { return ''; } var charLen = String.fromCodePoint(codePoint).length; return currentText.slice(textIndex, textIndex + charLen); } // move cursor to a new frame function newFrame() { if (currentFrame) { finishFrame(); } currentFrame = { options: ops, lines: [] }; currentLine = null; ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height); cursor = { x: frameMargin.left, y: frameMargin.top }; } // move cursor to head of a new line function newLine() { var fragOps = currFragment; if (!currentFrame) { newFrame(); } finishLine(); if (currentFrame.lines.length === 0) { // if there is no line in current frame, just start a new line without // checking available height, since a frame must be high enouth for one // line cursor = { x: frameMargin.left + fragOps.margin['left'], y: frameMargin.top }; } else { // cursor.y are based on start of text box, but maxFrameY is max y which // the end of text box can reach, so we need to minus an extra lineHeight var availableHeight = maxFrameY - (cursor.y + fragOps.lineHeight); if (availableHeight < fragOps.lineHeight) { newFrame(); // we don't plus the top margin of current fragment, because fragment // verticle margin should only be computed at the starting of fragment. cursor = { x: frameMargin.left + fragOps.margin['left'], y: frameMargin.top }; } else { cursor.x = frameMargin.left + fragOps.margin['left']; cursor.y += fragOps.lineHeight; } } currentLine = { chars: [], isLastLine: false, fragmentOptions: fragOps, offset: __assign({}, cursor), }; } function finishLine() { if (currentLine) { // if a line exist currently, commit it currentFrame.lines.push(currentLine); currentLine = null; } } function finishLastLine() { if (currentLine) { currentLine.isLastLine = true; } finishLine(); } // flush work on current frame function finishFrame() { if (currentFrame) { if (currentLine) { finishLine(); } frames.push(currentFrame); currentFrame = null; } } for (var _i = 0, _a = (ops.fragments || []); _i < _a.length; _i++) { var fragOps = _a[_i]; // flush work on previous fragment finishLastLine(); fragOps = currFragment = resolveFragmentOptions(options, fragOps); ctx.font = fragOps.fontWeight + ' ' + fragOps.fontSize + 'px ' + fragOps.fontFamily; newLine(); if (currentFrame.lines.length === 0) { // don't let new frame affected by remaining margin from previous frame remainingMargin = 0; } if (fragOps.marginCollapse && previousMarginCollapse) { remainingMargin = Math.max(fragOps.margin['top'], remainingMargin); } else { remainingMargin += fragOps.margin['top']; } if (currentFrame.lines.length > 0) { if (cursor.y + remainingMargin + fragOps.lineHeight > maxFrameY + 1) { // there is no enough space on current frame, get a new one currentLine = null; finishFrame(); newLine(); } else { cursor.y += remainingMargin; } } // not "else if". we check, or maybe re-check whether current frame is // empty, because current frame may be resetted above if (currentFrame.lines.length === 0 && !fragOps.noHeadMargin) { // current frame is empty, we don't create new frame anyway, so we can // skip checking space cursor.y = frameMargin.top + fragOps.margin['top']; } if (fragOps.textIndent > 0) { cursor.x += fragOps.textIndent; } // we moved cursor, and the slot of line start may have changed, reset it currentLine.offset.x = cursor.x; currentLine.offset.y = cursor.y; maxLineX = maxFrameX - fragOps.margin['right']; currentText = fragOps.text; textIndex = 0; while (textIndex < currentText.length) { var currentChar = nextChar(), currentCharLength = currentChar.length, currentCharWidth = ctx.measureText(currentChar).width, availableWidth = maxLineX + 1 - cursor.x; if (currentChar === '\n') { // current char is a carrige return, just move cusor to next line. // even if the last char of current line if a prohibition of line end, // we don't process it, since the paragraph has ended. finishLastLine(); newLine(); currentChar = null; currentCharWidth = 0; // we don't indent text since it's not the begining of a paragraph. } else if (availableWidth < currentCharWidth) { // there is no enough space to place current char, we need to turn to a // new line. // but before that, we have to process the prohibition of line start // and line end. var lineStartChar = currentChar, lineEndChar = void 0, newIndex = textIndex; for (var i = currentLine.chars.length - 1; i >= 0; --i) { lineEndChar = currentLine.chars[i]; // we check whether there is a prohibition, if true, try to fix. // if a prohibition found, we try to fix it through moving the // line-end char of current line to the start of next line. then // check, repeat. // if we can't fix when current line has only one char left, just do // nothing as if there was no prohibition found. if (!ops.lineStartProhibitedMarks.includes(lineStartChar) && !ops.lineEndProhibitedMarks.includes(lineEndChar) && !ops.unbreakableRule.test(lineEndChar + lineStartChar)) { // fix currentChar = lineStartChar; currentCharLength = currentChar.length; currentCharWidth = ctx.measureText(currentChar).width; textIndex = newIndex; currentLine.chars = currentLine.chars.slice(0, i + 1); break; } newIndex -= lineStartChar.length; lineStartChar = lineEndChar; } newLine(); } if (currentChar) { currentLine.chars.push(currentChar); } cursor.x += currentCharWidth; textIndex += currentCharLength; } remainingMargin = fragOps.margin['bottom']; previousMarginCollapse = fragOps.marginCollapse; } finishLastLine(); finishFrame(); return frames; } // prepare canvas to render text. function prepareCanvas(context, fragOptions, line) { var mergedOptions = fragOptions; if (mergedOptions) { context.font = mergedOptions.fontWeight + ' ' + mergedOptions.fontSize + 'px ' + mergedOptions.fontFamily; context.fillStyle = mergedOptions.color; // since context.direction is an experimental technology, we cannot rely on it context.direction = 'ltr'; var textAlign = mergedOptions.textAlign; if (line.isLastLine && mergedOptions.textAlign === 'justify') { textAlign = mergedOptions.textAlignLast; } if (textAlign === 'start') { context.textAlign = mergedOptions.rtl ? 'right' : 'left'; } else if (textAlign === 'end') { context.textAlign = mergedOptions.rtl ? 'left' : 'right'; } else if (['center', 'left', 'right'].includes(textAlign)) { context.textAlign = textAlign; } else { context.textAlign = 'left'; } context.textBaseline = 'middle'; } } // finish work on current line function renderLine(context, options, line) { if (line) { var fragOps = line.fragmentOptions, chars = fragOps.rtl ? line.chars.slice(0).reverse() : line.chars, frameMargin = options.margin, fragMargin = fragOps.margin; // draw text prepareCanvas(context, fragOps, line); var _a = line.offset, offsetX = _a.x, offsetY = _a.y, isJustifyMode = fragOps.textAlign === TextAlign.justify, textIndent = offsetX - frameMargin.left - fragOps.margin['left']; // offsetY is based on top of text box, and context.textBaseline is // 'middle', so plus a half line height offsetY += fragOps.lineHeight / 2; if (isJustifyMode && line.isLastLine && fragOps.textAlignLast !== TextAlign.justify) { isJustifyMode = false; } if (isJustifyMode) { var contentWidth = context.measureText(chars.join('')).width, remainWidth = (options.canvasWidth - contentWidth - frameMargin.left - frameMargin.right - fragMargin.left - fragMargin.right - textIndent), gap = remainWidth / (chars.length - 1); if (fragOps.rtl) { // initially, we assume using left-to-right mode, but it's right-to-left // mode now, we'll reverse the text and recompute offsetX, and we have // to "move" text indention to right offsetX -= textIndent; } for (var i = 0; i < chars.length; ++i) { context.fillText(chars[i], Math.round(offsetX), offsetY); offsetX += context.measureText(chars[i]).width + gap; } } else { // we use context.textAlign not fragOps.textAlign, because the first one // has been normalized when preparing canvas if (context.textAlign === 'center') { offsetX = context.canvas.width / 2; } else if (context.textAlign === 'right') { offsetX = (options.canvasWidth - frameMargin.right - fragOps.margin['right'] - textIndent); } context.fillText(chars.join(''), offsetX, offsetY); } line = null; } } function renderFrame(context, frame, clear) { if (!context) { throw Error("error: text-frame - must provide drawing context!"); } if (!frame) { context.clearRect(0, 0, context.canvas.width, context.canvas.height); return; } var canvasWidth = Math.round(frame.options.canvasWidth), canvasHeight = Math.round(frame.options.canvasHeight); if (clear || context.canvas.width !== canvasWidth || context.canvas.height !== canvasHeight) { context.canvas.width = canvasWidth; context.canvas.height = canvasHeight; } for (var _i = 0, _a = frame.lines; _i < _a.length; _i++) { var line = _a[_i]; renderLine(context, frame.options, line); } } export { computeTextFrames, renderFrame }; //# sourceMappingURL=text-frame.js.map