Add basic auto-render extension

Summary:
Add an auto-render extension to render math on a page. It
exposes a global function (maybe we should attach it to `katex`?) to
render math in an element. It comes with a README on how to use it.
Also, make `make build` build the minified file.

Fixes #26

Test Plan:
 - Visit http://localhost:7936/contrib/auto-render/
 - See that all of the math renders correctly
 - `make test`

Reviewers: alpert, kevinb

Reviewed By: kevinb

Differential Revision: https://phabricator.khanacademy.org/D16620
This commit is contained in:
Emily Eisenberg 2015-04-01 15:57:10 -07:00
parent 99a81aca50
commit cd9bca4a89
12 changed files with 595 additions and 35 deletions

View File

@ -1,6 +1,12 @@
The MIT License (MIT)
Copyright (c) 2014 Khan Academy
Copyright (c) 2015 Khan Academy
This software also uses portions of the underscore.js project, which is
MIT licensed with the following copyright:
Copyright (c) 2009-2015 Jeremy Ashkenas, DocumentCloud and Investigative
Reporters & Editors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
@ -18,4 +24,4 @@ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
SOFTWARE.

View File

@ -1,5 +1,13 @@
.PHONY: build lint setup copy serve clean metrics test zip
build: setup lint build/katex.min.js build/katex.min.css zip compress
.PHONY: build lint setup copy serve clean metrics test zip contrib
build: setup lint build/katex.min.js build/katex.min.css contrib zip compress
# Export these variables for use in contrib Makefiles
export BUILDDIR = $(realpath build)
export BROWSERIFY = $(realpath ./node_modules/.bin/browserify)
export UGLIFYJS = $(realpath ./node_modules/.bin/uglifyjs) \
--mangle \
--beautify \
ascii_only=true,beautify=false
setup:
npm install
@ -8,10 +16,10 @@ lint: katex.js $(wildcard src/*.js)
./node_modules/.bin/jshint $^
build/katex.js: katex.js $(wildcard src/*.js)
./node_modules/.bin/browserify $< --standalone katex > $@
$(BROWSERIFY) $< --standalone katex > $@
build/katex.min.js: build/katex.js
./node_modules/.bin/uglifyjs --mangle --beautify ascii_only=true,beautify=false < $< > $@
$(UGLIFYJS) < $< > $@
build/katex.less.css: static/katex.less $(wildcard static/*.less)
./node_modules/.bin/lessc $< $@
@ -27,15 +35,27 @@ build/fonts:
cp static/fonts/$$font* $@; \
done
contrib: build/contrib
.PHONY: build/contrib
build/contrib:
mkdir -p build/contrib
# Since everything in build/contrib is put in the built files, make sure
# there's nothing in there we don't want.
rm -rf build/contrib/*
$(MAKE) -C contrib/auto-render
.PHONY: build/katex
build/katex: build/katex.min.js build/katex.min.css build/fonts README.md
build/katex: build/katex.min.js build/katex.min.css build/fonts README.md build/contrib
mkdir -p build/katex
rm -rf build/katex/*
cp -r $^ build/katex
build/katex.tar.gz: build/katex
cd build && tar czf katex.tar.gz katex/
build/katex.zip: build/katex
rm -f $@
cd build && zip -rq katex.zip katex/
zip: build/katex.tar.gz build/katex.zip
@ -53,6 +73,7 @@ serve:
test:
./node_modules/.bin/jasmine-node test/katex-spec.js
./node_modules/.bin/jasmine-node contrib/auto-render/auto-render-spec.js
metrics:
cd metrics && ./mapping.pl | ./extract_tfms.py | ./extract_ttfs.py | ./replace_line.py

View File

@ -49,6 +49,10 @@ For example:
katex.render("c = \\pm\\sqrt{a^2 + b^2}", element, { displayMode: true });
```
#### Automatic rendering of math on a page
Math on the page can be automatically rendered using the auto-render extension. See [the Auto-render README](contrib/auto-render/README.md) for more information.
## Contributing
See [CONTRIBUTING.md](CONTRIBUTING.md)

View File

@ -0,0 +1,9 @@
.PHONY: build
build: $(BUILDDIR)/contrib/auto-render.min.js
$(BUILDDIR)/contrib/auto-render.min.js: $(BUILDDIR)/auto-render.js
$(UGLIFYJS) < $< > $@
$(BUILDDIR)/auto-render.js: auto-render.js
$(BROWSERIFY) $< --standalone renderMathInElement > $@

View File

@ -0,0 +1,63 @@
# Auto-render extension
This is an extension to automatically render all of the math inside of text. It
searches all of the text nodes in a given element for the given delimiters, and
renders the math in place.
### Usage
This extension isn't part of KaTeX proper, so the script should be separately
included in the page:
```html
<script src="/path/to/auto-render.min.js"></script>
```
Then, call the exposed `renderMathInElement` function in a script tag
before the close body tag:
```html
<body>
...
<script>
renderMathInElement(document.body);
</script>
</body>
```
See [index.html](index.html) for an example.
### API
This extension exposes a single function, `window.renderMathInElement`, with
the following API:
```js
function renderMathInElement(elem, options)
```
`elem` is an HTML DOM element. The function will recursively search for text
nodes inside this element and render the math in them.
`options` is an optional object argument with the following keys:
- `delimiters`: This is a list of delimiters to look for math. Each delimiter
has three properties:
- `left`: A string which starts the math expression (i.e. the left delimiter).
- `right`: A string which ends the math expression (i.e. the right delimiter).
- `display`: A boolean of whether the math in the expression should be
rendered in display mode or not.
The default value is:
```js
[
{left: "$$", right: "$$", display: true},
{left: "\\[", right: "\\]", display: true},
{left: "\\(", right: "\\)", display: false}
]
```
- `ignoredTags`: This is a list of DOM node types to ignore when recursing
through. The default value is
`["script", "noscript", "style", "textarea", "pre", "code"]`.

View File

@ -0,0 +1,217 @@
var splitAtDelimiters = require("./splitAtDelimiters");
beforeEach(function() {
jasmine.addMatchers({
toSplitInto: function() {
return {
compare: function(actual, left, right, result) {
var message = {
pass: true,
message: "'" + actual + "' split correctly"
};
var startData = [{type: "text", data: actual}];
var split = splitAtDelimiters(startData, left, right, false);
if (split.length !== result.length) {
message.pass = false;
message.message = "Different number of splits: " +
split.length + " vs. " + result.length + " (" +
JSON.stringify(split) + " vs. " +
JSON.stringify(result) + ")";
return message;
}
for (var i = 0; i < split.length; i++) {
var real = split[i];
var correct = result[i];
var good = true;
if (real.type !== correct.type) {
good = false;
diff = "type";
} else if (real.data !== correct.data) {
good = false;
diff = "data";
} else if (real.display !== correct.display) {
good = false;
diff = "display";
}
if (!good) {
message.pass = false;
message.message = "Difference at split " +
(i + 1) + ": " + JSON.stringify(real) +
" vs. " + JSON.stringify(correct) +
" (" + diff + " differs)";
break;
}
}
return message;
}
};
}
});
});
describe("A delimiter splitter", function() {
it("doesn't split when there are no delimiters", function() {
expect("hello").toSplitInto("(", ")", [{type: "text", data: "hello"}]);
});
it("doesn't create a math node when there's only a left delimiter", function() {
expect("hello ( world").toSplitInto(
"(", ")",
[
{type: "text", data: "hello "},
{type: "text", data: "( world"}
]);
});
it("doesn't split when there's only a right delimiter", function() {
expect("hello ) world").toSplitInto(
"(", ")",
[
{type: "text", data: "hello ) world"}
]);
});
it("splits when there are both delimiters", function() {
expect("hello ( world ) boo").toSplitInto(
"(", ")",
[
{type: "text", data: "hello "},
{type: "math", data: " world ", display: false},
{type: "text", data: " boo"}
]);
});
it("splits on multi-character delimiters", function() {
expect("hello [[ world ]] boo").toSplitInto(
"[[", "]]",
[
{type: "text", data: "hello "},
{type: "math", data: " world ", display: false},
{type: "text", data: " boo"}
]);
});
it("splits mutliple times", function() {
expect("hello ( world ) boo ( more ) stuff").toSplitInto(
"(", ")",
[
{type: "text", data: "hello "},
{type: "math", data: " world ", display: false},
{type: "text", data: " boo "},
{type: "math", data: " more ", display: false},
{type: "text", data: " stuff"}
]);
});
it("leaves the ending when there's only a left delimiter", function() {
expect("hello ( world ) boo ( left").toSplitInto(
"(", ")",
[
{type: "text", data: "hello "},
{type: "math", data: " world ", display: false},
{type: "text", data: " boo "},
{type: "text", data: "( left"}
]);
});
it("doesn't split when close delimiters are in {}s", function() {
expect("hello ( world { ) } ) boo").toSplitInto(
"(", ")",
[
{type: "text", data: "hello "},
{type: "math", data: " world { ) } ", display: false},
{type: "text", data: " boo"}
]);
expect("hello ( world { { } ) } ) boo").toSplitInto(
"(", ")",
[
{type: "text", data: "hello "},
{type: "math", data: " world { { } ) } ", display: false},
{type: "text", data: " boo"}
]);
});
it("doesn't split at escaped delimiters", function() {
expect("hello ( world \\) ) boo").toSplitInto(
"(", ")",
[
{type: "text", data: "hello "},
{type: "math", data: " world \\) ", display: false},
{type: "text", data: " boo"}
]);
/* TODO(emily): make this work maybe?
expect("hello \\( ( world ) boo").toSplitInto(
"(", ")",
[
{type: "text", data: "hello \\( "},
{type: "math", data: " world ", display: false},
{type: "text", data: " boo"}
]);
*/
});
it("splits when the right and left delimiters are the same", function() {
expect("hello $ world $ boo").toSplitInto(
"$", "$",
[
{type: "text", data: "hello "},
{type: "math", data: " world ", display: false},
{type: "text", data: " boo"}
]);
});
it("remembers which delimiters are display-mode", function() {
var startData = [{type: "text", data: "hello ( world ) boo"}];
expect(splitAtDelimiters(startData, "(", ")", true)).toEqual(
[
{type: "text", data: "hello "},
{type: "math", data: " world ", display: true},
{type: "text", data: " boo"}
]);
});
it("works with more than one start datum", function() {
var startData = [
{type: "text", data: "hello ( world ) boo"},
{type: "math", data: "math", display: true},
{type: "text", data: "hello ( world ) boo"}
];
expect(splitAtDelimiters(startData, "(", ")", false)).toEqual(
[
{type: "text", data: "hello "},
{type: "math", data: " world ", display: false},
{type: "text", data: " boo"},
{type: "math", data: "math", display: true},
{type: "text", data: "hello "},
{type: "math", data: " world ", display: false},
{type: "text", data: " boo"}
]);
});
it("doesn't do splitting inside of math nodes", function() {
var startData = [
{type: "text", data: "hello ( world ) boo"},
{type: "math", data: "hello ( world ) boo", display: true}
];
expect(splitAtDelimiters(startData, "(", ")", false)).toEqual(
[
{type: "text", data: "hello "},
{type: "math", data: " world ", display: false},
{type: "text", data: " boo"},
{type: "math", data: "hello ( world ) boo", display: true}
]);
});
});

View File

@ -0,0 +1,95 @@
var splitAtDelimiters = require("./splitAtDelimiters");
var splitWithDelimiters = function(text, delimiters) {
var data = [{type: "text", data: text}];
for (var i = 0; i < delimiters.length; i++) {
var delimiter = delimiters[i];
data = splitAtDelimiters(
data, delimiter.left, delimiter.right,
delimiter.display || false);
}
return data;
};
var renderMathInText = function(text, delimiters) {
var data = splitWithDelimiters(text, delimiters);
var fragment = document.createDocumentFragment();
for (var i = 0; i < data.length; i++) {
if (data[i].type === "text") {
fragment.appendChild(document.createTextNode(data[i].data));
} else {
var span = document.createElement("span");
var math = data[i].data;
katex.render(math, span, {
displayMode: data[i].display
});
fragment.appendChild(span);
}
}
return fragment;
};
var renderElem = function(elem, delimiters, ignoredTags) {
for (var i = 0; i < elem.childNodes.length; i++) {
var childNode = elem.childNodes[i];
if (childNode.nodeType === 3) {
// Text node
var frag = renderMathInText(childNode.textContent, delimiters);
i += frag.childNodes.length - 1;
elem.replaceChild(frag, childNode);
} else if (childNode.nodeType === 1) {
// Element node
var shouldRender = ignoredTags.indexOf(
childNode.nodeName.toLowerCase()) === -1;
if (shouldRender) {
renderElem(childNode, delimiters, ignoredTags);
}
} else {
// Something else, ignore
}
}
};
var defaultOptions = {
delimiters: [
{left: "$$", right: "$$", display: true},
{left: "\\[", right: "\\]", display: true},
{left: "\\(", right: "\\)", display: false}
// LaTeX uses this, but it ruins the display of normal `$` in text:
// {left: "$", right: "$", display: false}
],
ignoredTags: [
"script", "noscript", "style", "textarea", "pre", "code"
]
};
var extend = function(obj) {
// Adapted from underscore.js' `_.extend`. See LICENSE.txt for license.
var source, prop;
for (var i = 1, length = arguments.length; i < length; i++) {
source = arguments[i];
for (prop in source) {
if (Object.prototype.hasOwnProperty.call(source, prop)) {
obj[prop] = source[prop];
}
}
}
return obj;
};
var renderMathInElement = function(elem, options) {
if (!elem) {
throw new Error("No element provided to render");
}
options = extend({}, defaultOptions, options);
renderElem(elem, options.delimiters, options.ignoredTags);
};
module.exports = renderMathInElement;

View File

@ -0,0 +1,47 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Auto-render test</title>
<script src="/katex.js" type="text/javascript"></script>
<link href="/katex.css" rel="stylesheet" type="text/css">
<script src="./auto-render.js" type="text/javascript"></script>
<style type="text/css">
body {
margin: 0px;
padding: 0px;
font-size: 72px;
}
#test > .blue {
color: blue;
}
</style>
</head>
<body>
<div id="test">
This is some text $math \frac12$ other text
<span class="blue">
Other node \[ displaymath \frac{1}{2} \] blah $$ \int_2^3 $$
</span>
and some <!-- comment --> more text \(and math\) blah. And $math with a
\$ sign$.
<pre>
Stuff in a $pre tag$
</pre>
</div>
<script>
renderMathInElement(
document.getElementById("test"),
{
delimiters: [
{left: "$$", right: "$$", display: true},
{left: "\\[", right: "\\]", display: true},
{left: "$", right: "$", display: false},
{left: "\\(", right: "\\)", display: false}
]
}
);
</script>
</body>
</html>

View File

@ -0,0 +1,98 @@
var findEndOfMath = function(delimiter, text, startIndex) {
// Adapted from
// https://github.com/Khan/perseus/blob/master/src/perseus-markdown.jsx
var index = startIndex;
var braceLevel = 0;
var delimLength = delimiter.length;
while (index < text.length) {
var character = text[index];
if (braceLevel <= 0 &&
text.slice(index, index + delimLength) === delimiter) {
return index;
} else if (character === "\\") {
index++;
} else if (character === "{") {
braceLevel++;
} else if (character === "}") {
braceLevel--;
}
index++;
}
return -1;
};
var splitAtDelimiters = function(startData, leftDelim, rightDelim, display) {
var finalData = [];
for (var i = 0; i < startData.length; i++) {
if (startData[i].type === "text") {
var text = startData[i].data;
var lookingForLeft = true;
var currIndex = 0;
var nextIndex;
nextIndex = text.indexOf(leftDelim);
if (nextIndex !== -1) {
currIndex = nextIndex;
finalData.push({
type: "text",
data: text.slice(0, currIndex)
});
lookingForLeft = false;
}
while (true) {
if (lookingForLeft) {
nextIndex = text.indexOf(leftDelim, currIndex);
if (nextIndex === -1) {
break;
}
finalData.push({
type: "text",
data: text.slice(currIndex, nextIndex)
});
currIndex = nextIndex;
} else {
nextIndex = findEndOfMath(
rightDelim,
text,
currIndex + leftDelim.length);
if (nextIndex === -1) {
break;
}
finalData.push({
type: "math",
data: text.slice(
currIndex + leftDelim.length,
nextIndex),
display: display
});
currIndex = nextIndex + rightDelim.length;
}
lookingForLeft = !lookingForLeft;
}
finalData.push({
type: "text",
data: text.slice(currIndex)
});
} else {
finalData.push(startData[i]);
}
}
return finalData;
};
module.exports = splitAtDelimiters;

View File

@ -17,7 +17,7 @@
"browserify": "~2.29.1",
"clean-css": "~2.2.15",
"express": "~3.3.3",
"jasmine-node": "git://github.com/mhevery/jasmine-node.git#Jasmine2.0",
"jasmine-node": "2.0.0-beta4",
"jshint": "^2.5.6",
"less": "~1.7.5",
"uglify-js": "~2.4.15"

View File

@ -9,20 +9,33 @@ var app = express();
app.use(express.logger());
app.get("/katex.js", function(req, res, next) {
var b = browserify();
b.add("./katex");
var serveBrowserified = function(file, standaloneName) {
return function(req, res, next) {
var b = browserify();
b.add(file);
var stream = b.bundle({standalone: "katex"});
var options = {};
if (standaloneName) {
options.standalone = standaloneName;
}
var body = "";
stream.on("data", function(s) { body += s; });
stream.on("error", function(e) { next(e); });
stream.on("end", function() {
res.setHeader("Content-Type", "text/javascript");
res.send(body);
});
});
var stream = b.bundle(options);
var body = "";
stream.on("data", function(s) { body += s; });
stream.on("error", function(e) { next(e); });
stream.on("end", function() {
res.setHeader("Content-Type", "text/javascript");
res.send(body);
});
};
};
app.get("/katex.js", serveBrowserified("./katex", "katex"));
app.get("/test/katex-spec.js", serveBrowserified("./test/katex-spec"));
app.get("/contrib/auto-render/auto-render.js",
serveBrowserified("./contrib/auto-render/auto-render",
"renderMathInElement"));
app.get("/katex.css", function(req, res, next) {
fs.readFile("static/katex.less", {encoding: "utf8"}, function(err, data) {
@ -48,24 +61,10 @@ app.get("/katex.css", function(req, res, next) {
});
});
app.get("/test/katex-spec.js", function(req, res, next) {
var b = browserify();
b.add("./test/katex-spec");
var stream = b.bundle({});
var body = "";
stream.on("data", function(s) { body += s; });
stream.on("error", function(e) { next(e); });
stream.on("end", function() {
res.setHeader("Content-Type", "text/javascript");
res.send(body);
});
});
app.use(express.static(path.join(__dirname, "static")));
app.use(express.static(path.join(__dirname, "build")));
app.use("/test", express.static(path.join(__dirname, "test")));
app.use("/contrib", express.static(path.join(__dirname, "contrib")));
app.use(function(err, req, res, next) {
console.error(err.stack);

View File

@ -1,6 +1,7 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>KaTeX Test</title>
<script src="katex.js" type="text/javascript"></script>
<link href="katex.css" rel="stylesheet" type="text/css">