smoosic
Version:
<sub>[Github site](https://github.com/Smoosic/smoosic) | [source documentation](https://smoosic.github.io/Smoosic/release/docs/modules.html) | [change notes](https://aarondavidnewman.github.io/Smoosic/changes.html) | [application](https://smoosic.github.i
712 lines (695 loc) • 21.9 kB
text/typescript
// [Smoosic](https://github.com/AaronDavidNewman/Smoosic)
// Copyright (c) Aaron David Newman 2021.
/**
* Score Text is anything that isn't mapped specifically to a musical object.
* This includes score text, headers, footers. Score text is a single block of text.
* TextGroup is 1 or more ScoreText blocks arranged in some way.
* @module /smo/data/scoreModifier
*/
import { smoSerialize } from '../../common/serializationHelpers';
import { SmoScoreModifierBase, ScaledPageLayout } from './scoreModifiers';
import { SmoAttrs, SmoModifierBase, ElementLike } from './common';
import { SmoSelector } from '../xform/selections';
import { FontInfo } from '../../common/vex';
import { TextFormatter } from '../../common/textformatter';
/**
* Parameters for a single text block. Text blocks make up a text group.
* @category SmoObject
*/
export interface SmoScoreTextParams {
/**
* x location of font
*/
x: number,
/**
* location of font
*/
y: number,
/**
* In currently supported text groups, width and height comes from the text bounding box
* and so isn't required.
*/
width: number,
/**
* In currently supported text groups, width and height comes from the text bounding box
* and so isn't required.
*/
height: number,
/**
* The text content
*/
text: string,
/**
* Font of the text
*/
fontInfo: FontInfo,
/**
* defaults to black
*/
fill?: string,
classes?: string,
}
/**
* @category serialization
*/
export interface SmoScoreTextSer extends SmoScoreTextParams {
/**
* class name for deserialization
*/
ctor: string;
}
function isSmoScoreTextSer(params: Partial<SmoScoreTextSer>): params is SmoScoreTextSer {
if (!(params?.ctor === 'SmoScoreText')) {
return false;
}
return true;
}
/**
* Identify some text in the score, not associated with any musical element, like page
* decorations, titles etc.
* Note: score text is always contained in a text group. So this isn't directly accessed
* by score, but we keep the collection in score for backwards-compatibility
* @category SmoObject
*/
export class SmoScoreText extends SmoScoreModifierBase {
// convert EM to a number, or leave as a number etc.
static fontPointSize(size: string | number | undefined) {
let rv: number = 12;
if (typeof(size) !== 'number' && typeof(size) !== 'string') {
return rv;
}
const szz: string | number = size ?? 14;
if (typeof (szz) === 'number') {
return szz;
}
const ptString = szz.substring(0, szz.length - 2);
rv = parseFloat(ptString);
if (szz.indexOf('em') > 0) {
rv *= 14;
} else if (szz.indexOf('px') > 0) {
rv *= (96.0 / 72.0);
}
if (isNaN(rv)) {
rv = 12;
}
return rv;
}
/**
* Convert a numeric or string weight into either 'bold' or 'normal'
* @param fontWeight
* @returns
*/
static weightString(fontWeight: string | number | undefined): string {
let rv: string = 'normal';
if (fontWeight) {
const numForm = parseInt(fontWeight.toString(), 10);
if (isNaN(numForm)) {
rv = fontWeight.toString();
} else if (numForm > 500) {
rv = 'bold';
}
}
return rv;
}
static familyString(fam: string | undefined): string {
if (!fam) {
return SmoScoreText.fontFamilies.sansSerif;
}
return fam;
}
static get fontFamilies(): Record<string, string> {
return {
serif: 'Merriweather', sansSerif: 'Roboto,sans-serif', monospace: 'monospace', cursive: 'cursive',
times: 'Merriweather', arial: 'Arial'
};
}
static get parameters() {
return ['x', 'y', 'width', 'height', 'text', 'fontInfo', 'fill', 'classes']
}
static get defaults(): SmoScoreTextParams {
return JSON.parse(JSON.stringify({
x: 15,
y: 15,
width: 0,
height: 0,
text: 'Text',
fontInfo: {
size: 14,
family: SmoScoreText.fontFamilies.serif,
style: 'normal',
weight: 'normal'
},
fill: 'black',
classes: 'score-text',
}));
}
static deserialize(jsonObj: SmoScoreTextSer) {
const params = SmoScoreText.defaults;
smoSerialize.serializedMerge(SmoScoreText.parameters, jsonObj, params);
if (typeof (params.fontInfo.size === 'string')) {
params.fontInfo.size = SmoScoreText.fontPointSize(params.fontInfo.size);
}
return new SmoScoreText(params);
}
x: number = 15;
y: number = 15;
width: number = 0;
height: number = 0;
text: string = 'Text';
fontInfo: FontInfo = {
size: 14,
family: SmoScoreText.fontFamilies.serif,
style: 'normal',
weight: 'normal'
};
fill: string = 'black';
classes: string = 'score-text';
scaleX: number = 1.0;
scaleY: number = 1.0;
getText() {
return this.text;
}
estimateWidth(): number {
let i = 0;
let rv = 0;
const textFont = TextFormatter.create({
family: this.fontInfo.family,
size: this.fontInfo.size,
weight: this.fontInfo.weight,
style: this.fontInfo.style
});
textFont.setFontSize(SmoScoreText.fontPointSize(this.fontInfo.size));
for (i = 0; i < this.text.length; ++i) {
rv += textFont.getWidthForTextInPx(this.text[i]);
}
return rv;
}
tryParseUnicode() {
this.text = smoSerialize.tryParseUnicode(this.text);
}
offsetX(offset: number) {
this.x += offset;
}
offsetY(offset: number) {
this.y += offset;
}
serialize(): SmoScoreTextSer {
const params: Partial<SmoScoreTextSer> = {};
smoSerialize.serializedMergeNonDefault(SmoScoreText.defaults, SmoScoreText.attributes, this, params);
params.ctor = 'SmoScoreText';
if (!isSmoScoreTextSer(params)) {
throw ('bad score text ')
}
return params;
}
static get attributes(): string[] {
return ['x', 'y', 'text', 'fontInfo', 'classes',
'fill', 'width', 'height', 'scaleX', 'scaleY'];
}
static get simpleAttributes(): string[] {
return ['x', 'y', 'text', 'classes',
'fill', 'width', 'height', 'scaleX', 'scaleY'];
}
constructor(parameters: SmoScoreTextParams) {
super('SmoScoreText');
let rx = '';
smoSerialize.serializedMerge(SmoScoreText.attributes, SmoScoreText.defaults, this);
smoSerialize.serializedMerge(SmoScoreText.attributes, parameters, this);
if (!this.classes) {
this.classes = '';
}
if (this.classes.indexOf(this.attrs.id) < 0) {
this.classes += ' ' + this.attrs.id;
}
const weight = parameters.fontInfo ? parameters.fontInfo.weight : 'normal';
this.fontInfo.weight = SmoScoreText.weightString(weight ?? 'normal');
if (this.text) {
rx = smoSerialize.tryParseUnicode(this.text);
this.text = rx;
}
}
}
/**
* Each text block has the text data itself and some data about how it's placed
* @category SmoObject
*/
export interface SmoTextBlock {
/**
* The score text
*/
text: SmoScoreText,
/**
* position relative to other blocks
*/
position: number,
/**
* run-time flag
*/
activeText: boolean
}
/**
* @category SmoObject
*/
export interface SmoTextBlockSer {
/**
* The score text
*/
text: SmoScoreTextSer,
/**
* position relative to other blocks
*/
position: number
}
/**
* Used to place text imported from other formats, e.g. music xml
* @category SmoObject
*/
export interface SmoTextPlacement {
fontFamily: string,
fontSize: number,
xPlacement: number,
yOffset: number,
}
/**
* Constructor parameters for a text group, a block of text in Smoosic
* @param justification one of {@link SmoTextGroup.justifications}
* @param relativePosition relative position to other text groups
* @param pagination indicates if this text is paginated (goes on each page)
* @param spacing distance between blocks
* @param attachToSelector acts like 'note text' if attached to a note, otherwise
* the position is based on score position, or page position if paginated
* @param selector if attached, the selector in question
* @param textBlocks the actual textBlocks of text - a score text along with a placement parameter
* @category SmoObject
*/
export interface SmoTextGroupParams {
justification: number,
relativePosition: number,
pagination: number,
purpose: number,
spacing: number,
musicXOffset: number,
musicYOffset: number,
attachToSelector: boolean,
selector: SmoSelector,
textBlocks: SmoTextBlock[]
}
/**
* The serializable parts of a text group.
* @category serialization
*/
export interface SmoTextGroupParamsSer {
/**
* class name for deserialization
*/
ctor: string;
/**
* ID so we can identify which text this is in dialogs, UI
*/
attrs: SmoAttrs;
/**
* justification within the block
*/
justification?: number,
/**
* position (above, left, right etc)
*/
relativePosition?: number,
/**
* pagination for headers, footers
*/
pagination?: number,
/**
* spacing between blocks, future
*/
spacing?: number,
/**
* true if the text is attached to a note.
*/
attachToSelector?: boolean,
/**
* defined if the selector is attached to a note
*/
selector?: SmoSelector,
/**
* the individual text blocks
*/
textBlocks: SmoTextBlockSer[];
}
function isSmoTextGroupParamsSer(params: Partial<SmoTextGroupParamsSer>): params is SmoTextGroupParamsSer {
if (!(params?.ctor === 'SmoTextGroup')) {
return false;
}
if (!(typeof(params.attrs?.id) === 'string')) {
return false;
}
return true;
}
function isTextBlockSer(params: Partial<SmoTextBlockSer>): params is SmoTextBlockSer {
if (!params.text) {
return false;
}
if (!params.text) {
return false;
}
if (!(typeof(params.position) === 'number')) {
return false;
}
return true;
}
/**
* Suggestion for text purpose, maybe used to find a match..maybe not used at all
*/
export type SmoTextGroupPurpose = 'NONE' |'TITLE' | 'SUBTITLE' | 'COMPOSER' | 'COPYRIGHT';
/**
* @category SmoObject
*/
export interface SmoTextGroupContainer {
updateTextGroup: (textGroup: SmoTextGroup, toAdd: boolean) => void,
addTextGroup: (textGroup: SmoTextGroup) => void,
removeTextGroup: (textGroup: SmoTextGroup) => void
}
/**
* A grouping of text that can be used as a block for
* justification, alignment etc.
* @category SmoObject
*/
export class SmoTextGroup extends SmoScoreModifierBase {
static get justifications() {
return {
LEFT: 1,
RIGHT: 2,
CENTER: 3
};
}
static get paginations() {
return { EVERY: 1, EVENT: 2, ODD: 3, ONCE: 4, SUBSEQUENT: 5 };
}
// The position of block n relative to block n-1. Each block
// has it's own position. Justification is inter-block.
static get relativePositions() {
return { ABOVE: 1, BELOW: 2, LEFT: 3, RIGHT: 4 };
}
static get purposes(): Record<SmoTextGroupPurpose, number> {
return {
NONE: 1, TITLE: 2, SUBTITLE: 3, COMPOSER: 4, COPYRIGHT: 5
};
}
static get attributes() {
return ['textBlocks', 'justification', 'relativePosition', 'spacing', 'pagination',
'attachToSelector', 'selector', 'musicXOffset', 'musicYOffset'];
}
static get nonTextAttributes() {
return ['justification', 'relativePosition', 'spacing', 'pagination',
'attachToSelector', 'selector', 'musicXOffset', 'musicYOffset'];
}
static get simpleAttributes() {
return ['justification', 'relativePosition', 'spacing', 'pagination',
'attachToSelector', 'musicXOffset', 'musicYOffset'];
}
static isTextGroup(modifier: SmoTextGroup | SmoModifierBase): modifier is SmoTextGroup {
return modifier.ctor === 'SmoTextGroup';
}
static get purposeToFont(): Record<number | string, SmoTextPlacement> {
const rv: Record<number | string, SmoTextPlacement> = {};
rv[SmoTextGroup.purposes.TITLE] = {
fontFamily: 'Merriweather',
fontSize: 18,
xPlacement: 0.5,
yOffset: 4
};
rv[SmoTextGroup.purposes.SUBTITLE] = {
fontFamily: 'Merriweather',
fontSize: 16,
xPlacement: 0.5,
yOffset: 20,
};
rv[SmoTextGroup.purposes.COMPOSER] = {
fontFamily: 'Merriweather',
fontSize: 12,
xPlacement: 0.8,
yOffset: 10
};
rv[SmoTextGroup.purposes.COPYRIGHT] = {
fontFamily: 'Merriweather',
fontSize: 12,
xPlacement: 0.5,
yOffset: -12
};
return rv;
}
// ### createTextForLayout
// Create a specific score text type (title etc.) based on the supplied
// score layout
static createTextForLayout(purpose: number, text: string, layout: ScaledPageLayout) {
let x = 0;
const textAttr = SmoTextGroup.purposeToFont[purpose];
const pageWidth = layout.pageWidth;
const pageHeight = layout.pageHeight;
const bottomMargin = layout.bottomMargin;
const topMargin = layout.topMargin;
x = textAttr.xPlacement > 0 ? pageWidth * textAttr.xPlacement
: pageWidth - (pageWidth * textAttr.xPlacement);
const y = textAttr.yOffset > 0 ?
topMargin + textAttr.yOffset :
pageHeight + textAttr.yOffset - bottomMargin;
const defaults: SmoScoreTextParams = SmoScoreText.defaults;
const st = new SmoScoreText({
text, x, y, width: defaults.width, height: defaults.height,
fontInfo: { family: textAttr.fontFamily, size: textAttr.fontSize, weight: 'normal' }
});
const width = st.estimateWidth();
x -= width / 2;
const params = SmoTextGroup.defaults;
params.textBlocks = [{ text: st, position: SmoTextGroup.relativePositions.RIGHT, activeText: false }];
params.purpose = purpose;
const tg = new SmoTextGroup(params);
return tg;
}
static get defaults(): SmoTextGroupParams {
return JSON.parse(JSON.stringify({
textBlocks: [],
justification: SmoTextGroup.justifications.LEFT,
relativePosition: SmoTextGroup.relativePositions.RIGHT,
pagination: SmoTextGroup.paginations.ONCE,
purpose: SmoTextGroup.purposes.NONE,
spacing: 0,
attachToSelector: false,
selector: null,
musicXOffset: 0,
musicYOffset: 0
}));
}
justification: number = SmoTextGroup.justifications.LEFT;
relativePosition: number = SmoTextGroup.relativePositions.RIGHT;
pagination: number = SmoTextGroup.paginations.ONCE;
purpose: number = SmoTextGroup.purposes.NONE;
spacing: number = 0;
attachToSelector: boolean = false;
selector?: SmoSelector;
musicXOffset: number = 0;
musicYOffset: number = 0;
elements: ElementLike[] = [];
textBlocks: SmoTextBlock[] = [];
edited: boolean = false; // indicates not edited this session
skipRender: boolean = false; // don't render if it is being edited
static deserialize(jObj: SmoTextGroupParamsSer) {
const textBlocks: SmoTextBlock[] = [];
const params: any = {};
const jObjLegacy: any = jObj;
// handle parameter name change
if (jObjLegacy.blocks) {
jObj.textBlocks = jObjLegacy.blocks;
}
// Create new scoreText object for the text blocks
jObj.textBlocks.forEach((st: any) => {
const tx = SmoScoreText.deserialize(st.text);
textBlocks.push({ text: tx, position: st.position, activeText: false });
});
// fill in the textBlock configuration
smoSerialize.serializedMerge(SmoTextGroup.nonTextAttributes, jObj, params);
params.textBlocks = textBlocks;
return new SmoTextGroup(params);
}
static deserializePreserveId(jObj: any) {
const rv = SmoTextGroup.deserialize(jObj);
if (jObj.attrs.id) {
rv.attrs.id = jObj.attrs.id;
}
return rv;
}
// ### getPagedTextGroups
// If this text is repeated on page, create duplicates for each page, and
// resolve page numbers;
static getPagedTextGroups(tg: SmoTextGroup, pages: number, pageHeight: number): SmoTextGroup[] {
const rv: SmoTextGroup[] = [];
let i: number = 0;
if (tg.pagination === SmoTextGroup.paginations.ONCE) {
rv.push(tg);
return rv;
}
for (i = 0; i < pages; ++i) {
const ix: number = i;
const nblocks: any = [];
// deep copy the blocks so the page offsets don't bleed into
// original.
tg.textBlocks.forEach((block) => {
const nscoreText = new SmoScoreText(block.text);
nblocks.push({
text: nscoreText, position: block.position
});
});
const params: SmoTextGroupParams = {} as SmoTextGroupParams;
smoSerialize.serializedMerge(SmoTextGroup.nonTextAttributes, tg, params);
params.textBlocks = nblocks;
const ngroup: SmoTextGroup = new SmoTextGroup(params);
ngroup.textBlocks.forEach((block) => {
const xx = block.text;
xx.classes = 'score-text ' + xx.attrs.id;
xx.text = xx.text.replace('###', (ix + 1).toString()); /// page number
xx.text = xx.text.replace('@@@', pages.toString()); /// page number
xx.y += pageHeight * ix;
});
if (tg.logicalBox) {
ngroup.logicalBox = JSON.parse(JSON.stringify(tg.logicalBox));
ngroup.logicalBox!.y += pageHeight * i;
}
rv.push(ngroup);
}
return rv;
}
serialize(): SmoTextGroupParamsSer {
const params: Partial<SmoTextGroupParamsSer> = {
textBlocks: []
};
smoSerialize.serializedMergeNonDefault(SmoTextGroup.defaults, SmoTextGroup.nonTextAttributes, this, params);
this.textBlocks.forEach((blk: SmoTextBlock) => {
const blockSer: Partial<SmoTextBlockSer> = {
position: blk.position
}
blockSer.text = blk.text.serialize();
if (!isTextBlockSer(blockSer)) {
throw ('bad text block ' + JSON.stringify(blockSer));
}
params.textBlocks!.push(blockSer);
});
params.ctor = 'SmoTextGroup';
params.attrs = JSON.parse(JSON.stringify(this.attrs));
if (!isSmoTextGroupParamsSer(params)) {
throw('bad text group ' + JSON.stringify(params));
}
return params;
}
constructor(params: SmoTextGroupParams) {
super('SmoTextGroup');
if (typeof (params) === 'undefined') {
params = {} as SmoTextGroupParams;
}
this.textBlocks = [];
smoSerialize.serializedMerge(SmoTextGroup.nonTextAttributes, SmoTextGroup.defaults, this);
smoSerialize.serializedMerge(SmoTextGroup.nonTextAttributes, params, this);
if (params.textBlocks) {
params.textBlocks.forEach((block: SmoTextBlock) => {
this.textBlocks.push(block);
});
}
}
scaleText(scale: number) {
this.musicXOffset *= scale;
this.musicYOffset *= scale;
this.textBlocks.forEach((block: SmoTextBlock) => {
block.text.x *= scale;
block.text.y *= scale;
});
}
// ### tryParseUnicode
// Try to parse unicode strings.
tryParseUnicode() {
this.textBlocks.forEach((tb) => {
tb.text.tryParseUnicode();
});
}
estimateWidth() {
let rv = 0;
this.textBlocks.forEach((tb) => {
rv += tb.text.estimateWidth();
});
return rv;
}
// avoid saving text that can't be deleted
isTextVisible() {
let rv = true;
if (this.attachToSelector) {
return true;
}
this.textBlocks.forEach((block) => {
if (block.text.x < 0 || block.text.y < 0) {
rv = false;
}
});
return rv;
}
// ### setActiveBlock
// let the UI know which block is being edited. Parameter null means reset all
setActiveBlock(scoreText: SmoScoreText | null) {
this.textBlocks.forEach((block) => {
if (scoreText != null && block.text.attrs.id === scoreText.attrs.id) {
block.activeText = true;
} else {
block.activeText = false;
}
});
}
// For editing, keep track of the active text block.
getActiveBlock() {
const rv = this.textBlocks.find((block) => block.activeText === true);
if (typeof (rv) !== 'undefined') {
return rv.text;
}
return this.textBlocks[0].text;
}
setRelativePosition(position: number) {
this.textBlocks.forEach((block) => {
block.position = position;
});
this.relativePosition = position;
}
firstBlock() {
return this.textBlocks[0].text;
}
indexOf(scoreText: SmoScoreText) {
return this.textBlocks.findIndex((block) => block.text.attrs.id === scoreText.attrs.id);
}
addScoreText(scoreText: SmoScoreText, position: number = SmoTextGroup.relativePositions.LEFT) {
this.textBlocks.push({ text: scoreText, position, activeText: false });
}
ul() {
const rv = { x: 0, y: 0 };
this.textBlocks.forEach((block) => {
rv.x = block.text.x > rv.x ? block.text.x : rv.x;
rv.y = block.text.y > rv.y ? block.text.y : rv.y;
});
return rv;
}
removeBlock(scoreText: SmoScoreText) {
const bbid = (typeof (scoreText) === 'string') ? scoreText : scoreText.attrs.id;
const ix = this.textBlocks.findIndex((bb) => bb.text.attrs.id === bbid);
this.textBlocks.splice(ix, 1);
}
offsetX(offset: number) {
if (this.attachToSelector) {
this.musicXOffset += offset;
}
this.textBlocks.forEach((block) => {
block.text.offsetX(offset);
});
}
offsetY(offset: number) {
if (this.attachToSelector) {
this.musicYOffset += offset;
}
this.textBlocks.forEach((block) => {
block.text.offsetY(offset);
});
}
}