@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
JavaScript
/*! *****************************************************************************
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