first version
This commit is contained in:
@@ -0,0 +1,333 @@
|
||||
import { TextFrame, FrameLine, FragmentOptions, Margin, FrameOptions, TextAlign, resolveOptions, resolveFragmentOptions } from "./options";
|
||||
const ctx = document.createElement('canvas').getContext('2d');
|
||||
|
||||
export function computeTextFrames(options: FrameOptions) {
|
||||
const ops = resolveOptions(options || {} as any)
|
||||
, frames: TextFrame[] = []
|
||||
, frameMargin = ops.margin as Margin
|
||||
, maxFrameX = ops.canvasWidth - 1 - frameMargin.right
|
||||
, maxFrameY = ops.canvasHeight - 1 - frameMargin.bottom;
|
||||
let cursor = { x: 0, y: 0 }
|
||||
, textIndex = 0
|
||||
, maxLineX = 0
|
||||
, remainingMargin = 0
|
||||
, previousMarginCollapse = false
|
||||
, currFragment: FragmentOptions
|
||||
, currentText: string
|
||||
, currentLine: FrameLine
|
||||
, currentFrame: TextFrame;
|
||||
|
||||
/**
|
||||
* 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 ''; }
|
||||
const codePoint = currentText.codePointAt(textIndex);
|
||||
if (codePoint === undefined) { return ''; }
|
||||
const 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() {
|
||||
const 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
|
||||
const 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: { ...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 (let fragOps of (ops.fragments || [])) {
|
||||
// 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'];
|
||||
}
|
||||
else {
|
||||
// if current frame is not empty, we have processed above; if
|
||||
// fragOps.noHeadMargin is true, the cursor is set correctly when we
|
||||
// create current frame. so there is nothing to do.
|
||||
}
|
||||
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) {
|
||||
let 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.
|
||||
let lineStartChar = currentChar, lineEndChar, newIndex = textIndex;
|
||||
for (let 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: CanvasRenderingContext2D,
|
||||
fragOptions: FragmentOptions,
|
||||
line: FrameLine) {
|
||||
const 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';
|
||||
let 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 as any;
|
||||
}
|
||||
else {
|
||||
context.textAlign = 'left';
|
||||
}
|
||||
context.textBaseline = 'middle';
|
||||
}
|
||||
}
|
||||
|
||||
// finish work on current line
|
||||
function renderLine(
|
||||
context: CanvasRenderingContext2D,
|
||||
options: FrameOptions,
|
||||
line: FrameLine) {
|
||||
if (line) {
|
||||
const fragOps = line.fragmentOptions
|
||||
, chars = fragOps.rtl
|
||||
? line.chars.slice(0).reverse()
|
||||
: line.chars
|
||||
, frameMargin = options.margin as Margin
|
||||
, fragMargin = fragOps.margin as Margin;
|
||||
// draw text
|
||||
prepareCanvas(context, fragOps, line);
|
||||
let { x: offsetX, y: offsetY } = line.offset
|
||||
, 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) {
|
||||
const 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 (let 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;
|
||||
}
|
||||
}
|
||||
|
||||
export function renderFrame(context: CanvasRenderingContext2D, frame: TextFrame, clear?: boolean) {
|
||||
if (!context) {
|
||||
throw Error("error: text-frame - must provide drawing context!");
|
||||
}
|
||||
if (!frame) {
|
||||
context.clearRect(0, 0, context.canvas.width, context.canvas.height);
|
||||
return;
|
||||
}
|
||||
const 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 (const line of frame.lines) {
|
||||
renderLine(context, frame.options, line);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user