Add support for red text and highlighted text in markdown

This commit is contained in:
David Evans 2018-05-05 19:00:10 +01:00
parent dcad48ec90
commit b23e278729
20 changed files with 540 additions and 97 deletions

View File

@ -60,8 +60,58 @@
// No-op
}
addDefs() {
// No-op
addDefs(builder, textBuilder) {
// Thanks, https://stackoverflow.com/a/12263962/1180785
// https://bugs.chromium.org/p/chromium/issues/detail?id=603157
// https://bugzilla.mozilla.org/show_bug.cgi?id=917766
textBuilder('highlight', () => this.svg.el('filter')
.add(
// Morph makes characters consistent
this.svg.el('feMorphology').attrs({
'in': 'SourceAlpha',
'operator': 'dilate',
'radius': '4',
}),
// Blur+thresh makes edges smooth
this.svg.el('feGaussianBlur').attrs({
'edgeMode': 'none',
'stdDeviation': '3, 1.5',
}),
this.svg.el('feComponentTransfer').add(
this.svg.el('feFuncA').attrs({
'intercept': -70,
'slope': 100,
'type': 'linear',
})
),
// Add colour
this.svg.el('feComponentTransfer').add(
this.svg.el('feFuncR').attrs({
'intercept': 1,
'slope': 0,
'type': 'linear',
}),
this.svg.el('feFuncG').attrs({
'intercept': 0.9375,
'slope': 0,
'type': 'linear',
}),
this.svg.el('feFuncB').attrs({
'intercept': 0.09375,
'slope': 0,
'type': 'linear',
}),
this.svg.el('feFuncA').attrs({
'slope': 0.7,
'type': 'linear',
})
),
// Draw text on top
this.svg.el('feMerge').add(
this.svg.el('feMergeNode'),
this.svg.el('feMergeNode').attr('in', 'SourceGraphic')
)
));
}
getTitleAttrs() {
@ -3837,7 +3887,7 @@
start: /"/y,
},
{baseToken: {v: '...'}, start: /\.\.\./y},
{end: /(?=[ \t\r\n:+~\-*!<>,])|$/y, start: /(?=[^ \t\r\n:+~\-*!<>,])/y},
{end: /(?=[ \t\r\n:+~\-*!<,])|$/y, start: /(?=[^ \t\r\n:+~\-*!<,])/y},
{
end: /(?=[^~\-<>x])|[-~]x|[<>](?=x)|$/y,
includeEnd: true,
@ -4109,28 +4159,36 @@
const STYLES = [
{
attrs: {'font-style': 'italic'},
begin: /[\s_~`]\*(?=\S)/g,
end: /\S\*(?=[\s_~`])/g,
begin: {matcher: /[\s_~`>]\*(?=\S)/g, skip: 1},
end: {matcher: /\S\*(?=[\s_~`<])/g, skip: 1},
}, {
attrs: {'font-style': 'italic'},
begin: /[\s*~`]_(?=\S)/g,
end: /\S_(?=[\s*~`])/g,
begin: {matcher: /[\s*~`>]_(?=\S)/g, skip: 1},
end: {matcher: /\S_(?=[\s*~`<])/g, skip: 1},
}, {
attrs: {'font-weight': 'bolder'},
begin: /[\s_~`]\*\*(?=\S)/g,
end: /\S\*\*(?=[\s_~`])/g,
begin: {matcher: /[\s_~`>]\*\*(?=\S)/g, skip: 1},
end: {matcher: /\S\*\*(?=[\s_~`<])/g, skip: 1},
}, {
attrs: {'font-weight': 'bolder'},
begin: /[\s*~`]__(?=\S)/g,
end: /\S__(?=[\s*~`])/g,
begin: {matcher: /[\s*~`>]__(?=\S)/g, skip: 1},
end: {matcher: /\S__(?=[\s*~`<])/g, skip: 1},
}, {
attrs: {'text-decoration': 'line-through'},
begin: /[\s_*`]~(?=\S)/g,
end: /\S~(?=[\s_*`])/g,
begin: {matcher: /[\s_*`>]~(?=\S)/g, skip: 1},
end: {matcher: /\S~(?=[\s_*`<])/g, skip: 1},
}, {
attrs: {'font-family': 'Courier New,Liberation Mono,monospace'},
begin: /[\s_*~.]`(?=\S)/g,
end: /\S`(?=[\s_*~.])/g,
begin: {matcher: /[\s_*~.>]`(?=\S)/g, skip: 1},
end: {matcher: /\S`(?=[\s_*~.<])/g, skip: 1},
}, {
attrs: {'fill': '#DD0000'},
begin: {matcher: /<red>/g, skip: 0},
end: {matcher: /<\/red>/g, skip: 0},
}, {
attrs: {'filter': 'highlight'},
begin: {matcher: /<highlight>/g, skip: 0},
end: {matcher: /<\/highlight>/g, skip: 0},
},
];
@ -4142,19 +4200,20 @@
STYLES.forEach(({begin, end}, ind) => {
const search = active[ind] ? end : begin;
search.lastIndex = p;
const m = search.exec(virtLine);
if(m && (
m.index < bestStart ||
(m.index === bestStart && search.lastIndex > bestEnd)
)) {
search.matcher.lastIndex = p + 1 - search.skip;
const m = search.matcher.exec(virtLine);
const beginInd = m ? (m.index + search.skip) : Number.POSITIVE_INFINITY;
if(
beginInd < bestStart ||
(beginInd === bestStart && search.matcher.lastIndex > bestEnd)
) {
styleIndex = ind;
bestStart = m.index;
bestEnd = search.lastIndex;
bestStart = beginInd;
bestEnd = search.matcher.lastIndex;
}
});
return {end: bestEnd - 1, start: bestStart, styleIndex};
return {end: bestEnd - 1, start: bestStart - 1, styleIndex};
}
function combineAttrs(activeCount, active) {
@ -6776,7 +6835,9 @@
}
detach() {
this.element.parentNode.removeChild(this.element);
if(this.element.parentNode) {
this.element.parentNode.removeChild(this.element);
}
return this;
}
}
@ -6830,11 +6891,14 @@
throw new Error('Invalid formatted text line: ' + formattedLine);
}
formattedLine.forEach(({text, attrs}) => {
let element = text;
if(attrs) {
node.add(svg.el('tspan').attrs(attrs).add(text));
} else {
node.add(text);
element = svg.el('tspan').attrs(attrs).add(text);
if(attrs.filter) {
element.attr('filter', svg.getTextFilter(attrs.filter));
}
}
node.add(element);
});
}
@ -7282,12 +7346,40 @@
this.dom = domWrapper;
this.body = this.el('svg').attr('xmlns', NS).attr('version', '1.1');
const fn = (textSizerFactory || defaultTextSizerFactory);
this.textFilters = new Map();
this.textSizer = new TextSizerWrapper(fn(this));
this.txt = this.txt.bind(this);
this.el = this.el.bind(this);
}
resetTextFilters() {
this.textFilters.clear();
}
registerTextFilter(name, id) {
this.textFilters.set(name, {id, used: false});
}
getTextFilter(name) {
const filter = this.textFilters.get(name);
if(!filter) {
return 'none';
}
filter.used = true;
return 'url(#' + filter.id + ')';
}
getUsedTextFilterNames() {
const used = [];
for(const [name, filter] of this.textFilters) {
if(filter.used) {
used.push(name);
}
}
return used;
}
linearGradient(attrs, stops) {
return this.el('linearGradient')
.attrs(attrs)
@ -7508,6 +7600,7 @@
this.components = components || getComponents();
this.svg = new SVG(new DOMWrapper(document), textSizerFactory);
this.knownThemeDefs = new Set();
this.knownTextFilterDefs = new Map();
this.knownDefs = new Set();
this.highlights = new Map();
this.collapsed = new Set();
@ -7524,6 +7617,7 @@
this.prepareMeasurementsStage.bind(this);
this.renderStage = this.renderStage.bind(this);
this.addThemeDef = this.addThemeDef.bind(this);
this.addThemeTextDef = this.addThemeTextDef.bind(this);
this.addDef = this.addDef.bind(this);
}
@ -7582,6 +7676,15 @@
return namespacedName;
}
addThemeTextDef(name, generator) {
const namespacedName = this.namespace + name;
if(!this.knownTextFilterDefs.has(name)) {
const def = generator().attr('id', namespacedName);
this.knownTextFilterDefs.set(name, def);
}
this.svg.registerTextFilter(name, namespacedName);
}
addDef(name, generator) {
let nm = name;
let gen = generator;
@ -7963,6 +8066,7 @@
_reset(theme) {
if(theme) {
this.knownThemeDefs.clear();
this.knownTextFilterDefs.clear();
this.themeDefs.empty();
}
@ -8060,7 +8164,11 @@
this._reset(themeChanged);
this.metaCode.nodeValue = sequence.meta.code;
this.theme.addDefs(this.addThemeDef);
this.svg.resetTextFilters();
this.theme.addDefs(this.addThemeDef, this.addThemeTextDef);
for(const def of this.knownTextFilterDefs.values()) {
def.detach();
}
this.title.set({
attrs: Object.assign({
@ -8094,6 +8202,10 @@
sequence.stages.forEach(this.renderStage);
const bottomY = this.checkAgentRange(['[', ']'], this.currentY);
this.svg.getUsedTextFilterNames().forEach((name) => {
this.themeDefs.add(this.knownTextFilterDefs.get(name));
});
const stagesHeight = Math.max(bottomY - this.theme.actionMargin, 0);
this.updateBounds(stagesHeight);
@ -8962,7 +9074,7 @@
this.random.reset();
}
addDefs(builder) {
addDefs(builder, textBuilder) {
builder('sketch_font', () => {
const style = this.svg.el('style', null);
// Font must be embedded for exporting as SVG / PNG
@ -8974,6 +9086,8 @@
);
return style;
});
super.addDefs(builder, textBuilder);
}
vary(range, centre = 0) {

File diff suppressed because one or more lines are too long

View File

@ -60,8 +60,58 @@
// No-op
}
addDefs() {
// No-op
addDefs(builder, textBuilder) {
// Thanks, https://stackoverflow.com/a/12263962/1180785
// https://bugs.chromium.org/p/chromium/issues/detail?id=603157
// https://bugzilla.mozilla.org/show_bug.cgi?id=917766
textBuilder('highlight', () => this.svg.el('filter')
.add(
// Morph makes characters consistent
this.svg.el('feMorphology').attrs({
'in': 'SourceAlpha',
'operator': 'dilate',
'radius': '4',
}),
// Blur+thresh makes edges smooth
this.svg.el('feGaussianBlur').attrs({
'edgeMode': 'none',
'stdDeviation': '3, 1.5',
}),
this.svg.el('feComponentTransfer').add(
this.svg.el('feFuncA').attrs({
'intercept': -70,
'slope': 100,
'type': 'linear',
})
),
// Add colour
this.svg.el('feComponentTransfer').add(
this.svg.el('feFuncR').attrs({
'intercept': 1,
'slope': 0,
'type': 'linear',
}),
this.svg.el('feFuncG').attrs({
'intercept': 0.9375,
'slope': 0,
'type': 'linear',
}),
this.svg.el('feFuncB').attrs({
'intercept': 0.09375,
'slope': 0,
'type': 'linear',
}),
this.svg.el('feFuncA').attrs({
'slope': 0.7,
'type': 'linear',
})
),
// Draw text on top
this.svg.el('feMerge').add(
this.svg.el('feMergeNode'),
this.svg.el('feMergeNode').attr('in', 'SourceGraphic')
)
));
}
getTitleAttrs() {
@ -3837,7 +3887,7 @@
start: /"/y,
},
{baseToken: {v: '...'}, start: /\.\.\./y},
{end: /(?=[ \t\r\n:+~\-*!<>,])|$/y, start: /(?=[^ \t\r\n:+~\-*!<>,])/y},
{end: /(?=[ \t\r\n:+~\-*!<,])|$/y, start: /(?=[^ \t\r\n:+~\-*!<,])/y},
{
end: /(?=[^~\-<>x])|[-~]x|[<>](?=x)|$/y,
includeEnd: true,
@ -4109,28 +4159,36 @@
const STYLES = [
{
attrs: {'font-style': 'italic'},
begin: /[\s_~`]\*(?=\S)/g,
end: /\S\*(?=[\s_~`])/g,
begin: {matcher: /[\s_~`>]\*(?=\S)/g, skip: 1},
end: {matcher: /\S\*(?=[\s_~`<])/g, skip: 1},
}, {
attrs: {'font-style': 'italic'},
begin: /[\s*~`]_(?=\S)/g,
end: /\S_(?=[\s*~`])/g,
begin: {matcher: /[\s*~`>]_(?=\S)/g, skip: 1},
end: {matcher: /\S_(?=[\s*~`<])/g, skip: 1},
}, {
attrs: {'font-weight': 'bolder'},
begin: /[\s_~`]\*\*(?=\S)/g,
end: /\S\*\*(?=[\s_~`])/g,
begin: {matcher: /[\s_~`>]\*\*(?=\S)/g, skip: 1},
end: {matcher: /\S\*\*(?=[\s_~`<])/g, skip: 1},
}, {
attrs: {'font-weight': 'bolder'},
begin: /[\s*~`]__(?=\S)/g,
end: /\S__(?=[\s*~`])/g,
begin: {matcher: /[\s*~`>]__(?=\S)/g, skip: 1},
end: {matcher: /\S__(?=[\s*~`<])/g, skip: 1},
}, {
attrs: {'text-decoration': 'line-through'},
begin: /[\s_*`]~(?=\S)/g,
end: /\S~(?=[\s_*`])/g,
begin: {matcher: /[\s_*`>]~(?=\S)/g, skip: 1},
end: {matcher: /\S~(?=[\s_*`<])/g, skip: 1},
}, {
attrs: {'font-family': 'Courier New,Liberation Mono,monospace'},
begin: /[\s_*~.]`(?=\S)/g,
end: /\S`(?=[\s_*~.])/g,
begin: {matcher: /[\s_*~.>]`(?=\S)/g, skip: 1},
end: {matcher: /\S`(?=[\s_*~.<])/g, skip: 1},
}, {
attrs: {'fill': '#DD0000'},
begin: {matcher: /<red>/g, skip: 0},
end: {matcher: /<\/red>/g, skip: 0},
}, {
attrs: {'filter': 'highlight'},
begin: {matcher: /<highlight>/g, skip: 0},
end: {matcher: /<\/highlight>/g, skip: 0},
},
];
@ -4142,19 +4200,20 @@
STYLES.forEach(({begin, end}, ind) => {
const search = active[ind] ? end : begin;
search.lastIndex = p;
const m = search.exec(virtLine);
if(m && (
m.index < bestStart ||
(m.index === bestStart && search.lastIndex > bestEnd)
)) {
search.matcher.lastIndex = p + 1 - search.skip;
const m = search.matcher.exec(virtLine);
const beginInd = m ? (m.index + search.skip) : Number.POSITIVE_INFINITY;
if(
beginInd < bestStart ||
(beginInd === bestStart && search.matcher.lastIndex > bestEnd)
) {
styleIndex = ind;
bestStart = m.index;
bestEnd = search.lastIndex;
bestStart = beginInd;
bestEnd = search.matcher.lastIndex;
}
});
return {end: bestEnd - 1, start: bestStart, styleIndex};
return {end: bestEnd - 1, start: bestStart - 1, styleIndex};
}
function combineAttrs(activeCount, active) {
@ -6776,7 +6835,9 @@
}
detach() {
this.element.parentNode.removeChild(this.element);
if(this.element.parentNode) {
this.element.parentNode.removeChild(this.element);
}
return this;
}
}
@ -6830,11 +6891,14 @@
throw new Error('Invalid formatted text line: ' + formattedLine);
}
formattedLine.forEach(({text, attrs}) => {
let element = text;
if(attrs) {
node.add(svg.el('tspan').attrs(attrs).add(text));
} else {
node.add(text);
element = svg.el('tspan').attrs(attrs).add(text);
if(attrs.filter) {
element.attr('filter', svg.getTextFilter(attrs.filter));
}
}
node.add(element);
});
}
@ -7282,12 +7346,40 @@
this.dom = domWrapper;
this.body = this.el('svg').attr('xmlns', NS).attr('version', '1.1');
const fn = (textSizerFactory || defaultTextSizerFactory);
this.textFilters = new Map();
this.textSizer = new TextSizerWrapper(fn(this));
this.txt = this.txt.bind(this);
this.el = this.el.bind(this);
}
resetTextFilters() {
this.textFilters.clear();
}
registerTextFilter(name, id) {
this.textFilters.set(name, {id, used: false});
}
getTextFilter(name) {
const filter = this.textFilters.get(name);
if(!filter) {
return 'none';
}
filter.used = true;
return 'url(#' + filter.id + ')';
}
getUsedTextFilterNames() {
const used = [];
for(const [name, filter] of this.textFilters) {
if(filter.used) {
used.push(name);
}
}
return used;
}
linearGradient(attrs, stops) {
return this.el('linearGradient')
.attrs(attrs)
@ -7508,6 +7600,7 @@
this.components = components || getComponents();
this.svg = new SVG(new DOMWrapper(document), textSizerFactory);
this.knownThemeDefs = new Set();
this.knownTextFilterDefs = new Map();
this.knownDefs = new Set();
this.highlights = new Map();
this.collapsed = new Set();
@ -7524,6 +7617,7 @@
this.prepareMeasurementsStage.bind(this);
this.renderStage = this.renderStage.bind(this);
this.addThemeDef = this.addThemeDef.bind(this);
this.addThemeTextDef = this.addThemeTextDef.bind(this);
this.addDef = this.addDef.bind(this);
}
@ -7582,6 +7676,15 @@
return namespacedName;
}
addThemeTextDef(name, generator) {
const namespacedName = this.namespace + name;
if(!this.knownTextFilterDefs.has(name)) {
const def = generator().attr('id', namespacedName);
this.knownTextFilterDefs.set(name, def);
}
this.svg.registerTextFilter(name, namespacedName);
}
addDef(name, generator) {
let nm = name;
let gen = generator;
@ -7963,6 +8066,7 @@
_reset(theme) {
if(theme) {
this.knownThemeDefs.clear();
this.knownTextFilterDefs.clear();
this.themeDefs.empty();
}
@ -8060,7 +8164,11 @@
this._reset(themeChanged);
this.metaCode.nodeValue = sequence.meta.code;
this.theme.addDefs(this.addThemeDef);
this.svg.resetTextFilters();
this.theme.addDefs(this.addThemeDef, this.addThemeTextDef);
for(const def of this.knownTextFilterDefs.values()) {
def.detach();
}
this.title.set({
attrs: Object.assign({
@ -8094,6 +8202,10 @@
sequence.stages.forEach(this.renderStage);
const bottomY = this.checkAgentRange(['[', ']'], this.currentY);
this.svg.getUsedTextFilterNames().forEach((name) => {
this.themeDefs.add(this.knownTextFilterDefs.get(name));
});
const stagesHeight = Math.max(bottomY - this.theme.actionMargin, 0);
this.updateBounds(stagesHeight);
@ -8962,7 +9074,7 @@
this.random.reset();
}
addDefs(builder) {
addDefs(builder, textBuilder) {
builder('sketch_font', () => {
const style = this.svg.el('style', null);
// Font must be embedded for exporting as SVG / PNG
@ -8974,6 +9086,8 @@
);
return style;
});
super.addDefs(builder, textBuilder);
}
vary(range, centre = 0) {

View File

@ -164,7 +164,9 @@ class WrappedElement {
}
detach() {
this.element.parentNode.removeChild(this.element);
if(this.element.parentNode) {
this.element.parentNode.removeChild(this.element);
}
return this;
}
}

View File

@ -171,4 +171,11 @@ describe('SequenceDiagram', () => {
expect(content).toContain('<g class="region collapsed"');
});
it('includes text filters if used', () => {
const diagram = makeDiagram('title <highlight>foo</highlight>');
const content = getSimplifiedContent(diagram);
expect(content).toContain('<filter id="highlight"');
});
});

View File

@ -1,28 +1,36 @@
const STYLES = [
{
attrs: {'font-style': 'italic'},
begin: /[\s_~`]\*(?=\S)/g,
end: /\S\*(?=[\s_~`])/g,
begin: {matcher: /[\s_~`>]\*(?=\S)/g, skip: 1},
end: {matcher: /\S\*(?=[\s_~`<])/g, skip: 1},
}, {
attrs: {'font-style': 'italic'},
begin: /[\s*~`]_(?=\S)/g,
end: /\S_(?=[\s*~`])/g,
begin: {matcher: /[\s*~`>]_(?=\S)/g, skip: 1},
end: {matcher: /\S_(?=[\s*~`<])/g, skip: 1},
}, {
attrs: {'font-weight': 'bolder'},
begin: /[\s_~`]\*\*(?=\S)/g,
end: /\S\*\*(?=[\s_~`])/g,
begin: {matcher: /[\s_~`>]\*\*(?=\S)/g, skip: 1},
end: {matcher: /\S\*\*(?=[\s_~`<])/g, skip: 1},
}, {
attrs: {'font-weight': 'bolder'},
begin: /[\s*~`]__(?=\S)/g,
end: /\S__(?=[\s*~`])/g,
begin: {matcher: /[\s*~`>]__(?=\S)/g, skip: 1},
end: {matcher: /\S__(?=[\s*~`<])/g, skip: 1},
}, {
attrs: {'text-decoration': 'line-through'},
begin: /[\s_*`]~(?=\S)/g,
end: /\S~(?=[\s_*`])/g,
begin: {matcher: /[\s_*`>]~(?=\S)/g, skip: 1},
end: {matcher: /\S~(?=[\s_*`<])/g, skip: 1},
}, {
attrs: {'font-family': 'Courier New,Liberation Mono,monospace'},
begin: /[\s_*~.]`(?=\S)/g,
end: /\S`(?=[\s_*~.])/g,
begin: {matcher: /[\s_*~.>]`(?=\S)/g, skip: 1},
end: {matcher: /\S`(?=[\s_*~.<])/g, skip: 1},
}, {
attrs: {'fill': '#DD0000'},
begin: {matcher: /<red>/g, skip: 0},
end: {matcher: /<\/red>/g, skip: 0},
}, {
attrs: {'filter': 'highlight'},
begin: {matcher: /<highlight>/g, skip: 0},
end: {matcher: /<\/highlight>/g, skip: 0},
},
];
@ -34,19 +42,20 @@ function findNext(line, p, active) {
STYLES.forEach(({begin, end}, ind) => {
const search = active[ind] ? end : begin;
search.lastIndex = p;
const m = search.exec(virtLine);
if(m && (
m.index < bestStart ||
(m.index === bestStart && search.lastIndex > bestEnd)
)) {
search.matcher.lastIndex = p + 1 - search.skip;
const m = search.matcher.exec(virtLine);
const beginInd = m ? (m.index + search.skip) : Number.POSITIVE_INFINITY;
if(
beginInd < bestStart ||
(beginInd === bestStart && search.matcher.lastIndex > bestEnd)
) {
styleIndex = ind;
bestStart = m.index;
bestEnd = search.lastIndex;
bestStart = beginInd;
bestEnd = search.matcher.lastIndex;
}
});
return {end: bestEnd - 1, start: bestStart, styleIndex};
return {end: bestEnd - 1, start: bestStart - 1, styleIndex};
}
function combineAttrs(activeCount, active) {

View File

@ -117,6 +117,26 @@ describe('Markdown Parser', () => {
]]);
});
it('recognises red styling', () => {
const formatted = parser('a <red>b</red> c');
expect(formatted).toEqual([[
{attrs: null, text: 'a '},
{attrs: {'fill': '#DD0000'}, text: 'b'},
{attrs: null, text: ' c'},
]]);
});
it('recognises highlight styling', () => {
const formatted = parser('a <highlight>b</highlight> c');
expect(formatted).toEqual([[
{attrs: null, text: 'a '},
{attrs: {'filter': 'highlight'}, text: 'b'},
{attrs: null, text: ' c'},
]]);
});
it('allows dots around monospace styling', () => {
const formatted = parser('a.`b`.c');
@ -140,6 +160,18 @@ describe('Markdown Parser', () => {
]]);
});
it('recognises heavily combined styling', () => {
const formatted = parser('**_`abc`_**');
expect(formatted).toEqual([[
{attrs: {
'font-family': MONO_FONT,
'font-style': 'italic',
'font-weight': 'bolder',
}, text: 'abc'},
]]);
});
it('allows complex interactions between styles', () => {
const formatted = parser('_a **b_ ~c~**');

View File

@ -22,7 +22,7 @@ const TOKENS = [
start: /"/y,
},
{baseToken: {v: '...'}, start: /\.\.\./y},
{end: /(?=[ \t\r\n:+~\-*!<>,])|$/y, start: /(?=[^ \t\r\n:+~\-*!<>,])/y},
{end: /(?=[ \t\r\n:+~\-*!<,])|$/y, start: /(?=[^ \t\r\n:+~\-*!<,])/y},
{
end: /(?=[^~\-<>x])|[-~]x|[<>](?=x)|$/y,
includeEnd: true,

View File

@ -1,3 +1,5 @@
/* eslint-disable max-statements */
import Tokeniser from './Tokeniser.mjs';
describe('Sequence Tokeniser', () => {
@ -89,6 +91,20 @@ describe('Sequence Tokeniser', () => {
]);
});
it('accepts XML tag-like syntax', () => {
const input = 'abc <def> >> ghi <<';
const tokens = tokeniser.tokenise(input);
expect(tokens).toEqual([
token({s: '', v: 'abc'}),
token({s: ' ', v: '<'}),
token({s: '', v: 'def>'}),
token({s: ' ', v: '>>'}),
token({s: ' ', v: 'ghi'}),
token({s: ' ', v: '<<'}),
]);
});
it('stores line numbers', () => {
const input = 'foo bar\nbaz';
const tokens = tokeniser.tokenise(input);

View File

@ -76,6 +76,7 @@ export default class Renderer extends EventObject {
this.components = components || getComponents();
this.svg = new SVG(new DOMWrapper(document), textSizerFactory);
this.knownThemeDefs = new Set();
this.knownTextFilterDefs = new Map();
this.knownDefs = new Set();
this.highlights = new Map();
this.collapsed = new Set();
@ -92,6 +93,7 @@ export default class Renderer extends EventObject {
this.prepareMeasurementsStage.bind(this);
this.renderStage = this.renderStage.bind(this);
this.addThemeDef = this.addThemeDef.bind(this);
this.addThemeTextDef = this.addThemeTextDef.bind(this);
this.addDef = this.addDef.bind(this);
}
@ -150,6 +152,15 @@ export default class Renderer extends EventObject {
return namespacedName;
}
addThemeTextDef(name, generator) {
const namespacedName = this.namespace + name;
if(!this.knownTextFilterDefs.has(name)) {
const def = generator().attr('id', namespacedName);
this.knownTextFilterDefs.set(name, def);
}
this.svg.registerTextFilter(name, namespacedName);
}
addDef(name, generator) {
let nm = name;
let gen = generator;
@ -531,6 +542,7 @@ export default class Renderer extends EventObject {
_reset(theme) {
if(theme) {
this.knownThemeDefs.clear();
this.knownTextFilterDefs.clear();
this.themeDefs.empty();
}
@ -628,7 +640,11 @@ export default class Renderer extends EventObject {
this._reset(themeChanged);
this.metaCode.nodeValue = sequence.meta.code;
this.theme.addDefs(this.addThemeDef);
this.svg.resetTextFilters();
this.theme.addDefs(this.addThemeDef, this.addThemeTextDef);
for(const def of this.knownTextFilterDefs.values()) {
def.detach();
}
this.title.set({
attrs: Object.assign({
@ -662,6 +678,10 @@ export default class Renderer extends EventObject {
sequence.stages.forEach(this.renderStage);
const bottomY = this.checkAgentRange(['[', ']'], this.currentY);
this.svg.getUsedTextFilterNames().forEach((name) => {
this.themeDefs.add(this.knownTextFilterDefs.get(name));
});
const stagesHeight = Math.max(bottomY - this.theme.actionMargin, 0);
this.updateBounds(stagesHeight);

View File

@ -57,8 +57,58 @@ export default class BaseTheme {
// No-op
}
addDefs() {
// No-op
addDefs(builder, textBuilder) {
// Thanks, https://stackoverflow.com/a/12263962/1180785
// https://bugs.chromium.org/p/chromium/issues/detail?id=603157
// https://bugzilla.mozilla.org/show_bug.cgi?id=917766
textBuilder('highlight', () => this.svg.el('filter')
.add(
// Morph makes characters consistent
this.svg.el('feMorphology').attrs({
'in': 'SourceAlpha',
'operator': 'dilate',
'radius': '4',
}),
// Blur+thresh makes edges smooth
this.svg.el('feGaussianBlur').attrs({
'edgeMode': 'none',
'stdDeviation': '3, 1.5',
}),
this.svg.el('feComponentTransfer').add(
this.svg.el('feFuncA').attrs({
'intercept': -70,
'slope': 100,
'type': 'linear',
})
),
// Add colour
this.svg.el('feComponentTransfer').add(
this.svg.el('feFuncR').attrs({
'intercept': 1,
'slope': 0,
'type': 'linear',
}),
this.svg.el('feFuncG').attrs({
'intercept': 0.9375,
'slope': 0,
'type': 'linear',
}),
this.svg.el('feFuncB').attrs({
'intercept': 0.09375,
'slope': 0,
'type': 'linear',
}),
this.svg.el('feFuncA').attrs({
'slope': 0.7,
'type': 'linear',
})
),
// Draw text on top
this.svg.el('feMerge').add(
this.svg.el('feMergeNode'),
this.svg.el('feMergeNode').attr('in', 'SourceGraphic')
)
));
}
getTitleAttrs() {

View File

@ -350,7 +350,7 @@ export default class SketchTheme extends BaseTheme {
this.random.reset();
}
addDefs(builder) {
addDefs(builder, textBuilder) {
builder('sketch_font', () => {
const style = this.svg.el('style', null);
// Font must be embedded for exporting as SVG / PNG
@ -362,6 +362,8 @@ export default class SketchTheme extends BaseTheme {
);
return style;
});
super.addDefs(builder, textBuilder);
}
vary(range, centre = 0) {

View File

@ -189,12 +189,40 @@ export default class SVG {
this.dom = domWrapper;
this.body = this.el('svg').attr('xmlns', NS).attr('version', '1.1');
const fn = (textSizerFactory || defaultTextSizerFactory);
this.textFilters = new Map();
this.textSizer = new TextSizerWrapper(fn(this));
this.txt = this.txt.bind(this);
this.el = this.el.bind(this);
}
resetTextFilters() {
this.textFilters.clear();
}
registerTextFilter(name, id) {
this.textFilters.set(name, {id, used: false});
}
getTextFilter(name) {
const filter = this.textFilters.get(name);
if(!filter) {
return 'none';
}
filter.used = true;
return 'url(#' + filter.id + ')';
}
getUsedTextFilterNames() {
const used = [];
for(const [name, filter] of this.textFilters) {
if(filter.used) {
used.push(name);
}
}
return used;
}
linearGradient(attrs, stops) {
return this.el('linearGradient')
.attrs(attrs)

View File

@ -15,11 +15,14 @@ function populateSvgTextLine(svg, node, formattedLine) {
throw new Error('Invalid formatted text line: ' + formattedLine);
}
formattedLine.forEach(({text, attrs}) => {
let element = text;
if(attrs) {
node.add(svg.el('tspan').attrs(attrs).add(text));
} else {
node.add(text);
element = svg.el('tspan').attrs(attrs).add(text);
if(attrs.filter) {
element.attr('filter', svg.getTextFilter(attrs.filter));
}
}
node.add(element);
});
}

View File

@ -76,6 +76,17 @@ describe('SVGTextBlock', () => {
.toEqual('foo<tspan zig="zag">bar</tspan>');
});
it('converts filter attributes using the registered filters', () => {
svg.registerTextFilter('foo', 'local-foobar');
block.set({formatted: [[
{text: 'foo'},
{attrs: {'filter': 'foo'}, text: 'bar'},
]]});
expect(hold.childNodes[0].innerHTML)
.toEqual('foo<tspan filter="url(#local-foobar)">bar</tspan>');
});
it('re-uses text nodes when possible, adding more if needed', () => {
block.set({formatted: [[{text: 'foo'}], [{text: 'bar'}]]});
const [line0, line1] = hold.childNodes;

View File

@ -461,9 +461,20 @@ ImageRegion.loadURL = function(url, size = {}) {
};
ImageRegion.loadSVG = function(svg, size = {}) {
const blob = new Blob([svg], {type: 'image/svg+xml'});
// Safari workaround:
// Tweak SVG size directly rather than resizing image to keep quality high
const resolution = size.resolution || 1;
const svgCode = svg.replace(
/<svg width="([^"]+)" height="([^"]+)"/,
(m, w, h) => (
'<svg width="' + (w * resolution) +
'" height="' + (h * resolution) + '"'
)
);
const blob = new Blob([svgCode], {type: 'image/svg+xml'});
const url = URL.createObjectURL(blob);
return ImageRegion.loadURL(url, size)
return ImageRegion.loadURL(url, Object.assign({}, size, {resolution: 1}))
.then((region) => {
URL.revokeObjectURL(url);
return region;

View File

@ -1,3 +1,5 @@
<svg width="86.02510070800781" height="98" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="-43.012550354003906 -93 86.02510070800781 98"><metadata>title "Foo **Bar**
<svg width="96.69921875" height="124" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="-48.349609375 -119 96.69921875 124"><metadata>title "Foo **Bar**
_Baz_ `Zig`
**_`Zag`_**"</metadata><defs></defs><defs><mask id="R0FullMask" maskUnits="userSpaceOnUse"><rect fill="#FFFFFF" height="98" width="86.02510070800781" x="-43.012550354003906" y="-93"></rect></mask><mask id="R0LineMask" maskUnits="userSpaceOnUse"><rect fill="#FFFFFF" height="98" width="86.02510070800781" x="-43.012550354003906" y="-93"></rect></mask></defs><g></g><g><text x="0" font-family="Helvetica,Arial,Liberation Sans,sans-serif" font-size="20" line-height="1.3" text-anchor="middle" class="title" y="-68">Foo <tspan font-weight="bolder">Bar</tspan></text><text x="0" font-family="Helvetica,Arial,Liberation Sans,sans-serif" font-size="20" line-height="1.3" text-anchor="middle" class="title" y="-42"><tspan font-style="italic">Baz</tspan> <tspan font-family="Courier New,Liberation Mono,monospace">Zig</tspan></text><text x="0" font-family="Helvetica,Arial,Liberation Sans,sans-serif" font-size="20" line-height="1.3" text-anchor="middle" class="title" y="-16"><tspan font-style="italic" font-weight="bolder" font-family="Courier New,Liberation Mono,monospace">Zag</tspan></text></g><g></g><g mask="url(#R0FullMask)"><g mask="url(#R0LineMask)"></g><g></g><g></g></g></svg>
**_`Zag`_**
&lt;highlight&gt;Back&lt;/highlight&gt; &lt;red&gt;Text&lt;/red&gt;"
</metadata><defs><filter id="R0highlight"><feMorphology in="SourceAlpha" operator="dilate" radius="4"></feMorphology><feGaussianBlur edgeMode="none" stdDeviation="3, 1.5"></feGaussianBlur><feComponentTransfer><feFuncA intercept="-70" slope="100" type="linear"></feFuncA></feComponentTransfer><feComponentTransfer><feFuncR intercept="1" slope="0" type="linear"></feFuncR><feFuncG intercept="0.9375" slope="0" type="linear"></feFuncG><feFuncB intercept="0.09375" slope="0" type="linear"></feFuncB><feFuncA slope="0.7" type="linear"></feFuncA></feComponentTransfer><feMerge><feMergeNode></feMergeNode><feMergeNode in="SourceGraphic"></feMergeNode></feMerge></filter></defs><defs><mask id="R0FullMask" maskUnits="userSpaceOnUse"><rect fill="#FFFFFF" height="124" width="96.69921875" x="-48.349609375" y="-119"></rect></mask><mask id="R0LineMask" maskUnits="userSpaceOnUse"><rect fill="#FFFFFF" height="124" width="96.69921875" x="-48.349609375" y="-119"></rect></mask></defs><g></g><g><text x="0" class="title" font-family="Helvetica,Arial,Liberation Sans,sans-serif" font-size="20" line-height="1.3" text-anchor="middle" y="-94">Foo <tspan font-weight="bolder">Bar</tspan></text><text x="0" class="title" font-family="Helvetica,Arial,Liberation Sans,sans-serif" font-size="20" line-height="1.3" text-anchor="middle" y="-68"><tspan font-style="italic">Baz</tspan> <tspan font-family="Courier New,Liberation Mono,monospace">Zig</tspan></text><text x="0" class="title" font-family="Helvetica,Arial,Liberation Sans,sans-serif" font-size="20" line-height="1.3" text-anchor="middle" y="-42"><tspan font-style="italic" font-weight="bolder" font-family="Courier New,Liberation Mono,monospace">Zag</tspan></text><text x="0" class="title" font-family="Helvetica,Arial,Liberation Sans,sans-serif" font-size="20" line-height="1.3" text-anchor="middle" y="-16"><tspan filter="url(#R0highlight)">Back</tspan> <tspan fill="#DD0000">Text</tspan></text></g><g></g><g mask="url(#R0FullMask)"><g mask="url(#R0LineMask)"></g><g></g><g></g></g></svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

@ -266,6 +266,16 @@
preview: 'A -> B: `mono`',
title: 'Monospace markdown',
},
{
code: '<red>{text}</red>',
preview: 'A -> B: <red>red</red>',
title: 'Red markdown',
},
{
code: '<highlight>{text}</highlight>',
preview: 'A -> B: <highlight>highlight</highlight>',
title: 'Highlight markdown',
},
{
code: '{Agent} is red',
preview: 'headers box\nA is red\nbegin A',
@ -479,7 +489,9 @@
}
detach() {
this.element.parentNode.removeChild(this.element);
if(this.element.parentNode) {
this.element.parentNode.removeChild(this.element);
}
return this;
}
}

File diff suppressed because one or more lines are too long

View File

@ -263,6 +263,16 @@ export default [
preview: 'A -> B: `mono`',
title: 'Monospace markdown',
},
{
code: '<red>{text}</red>',
preview: 'A -> B: <red>red</red>',
title: 'Red markdown',
},
{
code: '<highlight>{text}</highlight>',
preview: 'A -> B: <highlight>highlight</highlight>',
title: 'Highlight markdown',
},
{
code: '{Agent} is red',
preview: 'headers box\nA is red\nbegin A',