Skip to content

Instantly share code, notes, and snippets.

@mattmccray
Created August 24, 2008 21:53
Show Gist options
  • Save mattmccray/7010 to your computer and use it in GitHub Desktop.
Save mattmccray/7010 to your computer and use it in GitHub Desktop.
//
// Graphic Novelist (graphic-novelist.js)
// Created by M@ McCray
// site: http://mattmccray.com
// email: matt at elucidata dot net
//
// API:
//
// GraphicNovelist.parse( text ) -> Parse text into array of script Nodes
// GraphicNovelist.renderNodes( nodes ) -> Renders array of script Nodes into HTML
// GraphicNovelist.render( text ) -> Parses and renders supplied text, returning HTML
// GraphicNovelist.renderTo( elementId, text ) -> Parses, renders, and replaces element's innerHTML with generated HTML
// Example script:
/*
@ Title: Our Hero
@ Author: Matt McCray
@ Revision: First Draft
[Page 1]
Panel 1 - Wide shot of The City. Our hero is patrolling the skies, ever
alert for any disturbances.
Panel 2 - Medium shot of the hero, getting a surprise message on his headset.
SFX: *crackle* *bzzz*
HERO: (confused) Yes? Who is this?
HEADSET: POTUS speaking. There's an urgent issue that requires your
unique, how shall we say? SKILLS.
Panel 3 - Close up on Hero. It seems that our hero is surprised to hear from
the President. But really, who wouldn't be?
HERO: Oh, uh, yes. I'd be happy to help out your, uh, majesty.
Er, highness? Uh...
HEADSET: Sir is fine.
HERO: Right, of course -- sir.
Panel 4 - Wide shot of our Hero pulling a 'U'-ey in the air, flying to the
President's secret office in Lincoln's skull at Mount Rushmore.
*/
// CSS
/*
.graphic-novelist {
font-family: "Courier New", courier, monospace;
font-size: 13px;
width: 500px;
}
.graphic-novelist .title {
text-align: center;
}
.graphic-novelist .metadata {
margin: 0 auto;
font-size: 95%;
}
.graphic-novelist .metadata TH {
font-weight: normal;
text-align: right;
color: #777;
}
.graphic-novelist .panel .number {
font-weight: bold;
}
.graphic-novelist .dialog .character, .graphic-novelist .sfx .character {
width: 120px;
text-align: right;
float: left;
}
.graphic-novelist .dialog .parenthetical, .graphic-novelist .sfx .parenthetical {
font-style: italic;
font-size: 90%;
color: #444;
}
.graphic-novelist .dialog .count, .graphic-novelist .sfx .count {
text-decoration: none;
padding-top: 1px;
font-size: 85%;
color: #999;
font-weight: normal;
}
.graphic-novelist .dialog .body, .graphic-novelist .sfx .body {
margin-left: 125px;
padding-left: 0px;
}
@media print {
.graphic-novelist {
width: auto;
font-size: 12px;
}
.graphic-novelist .page {
page-break-before: always;
}
}
*/
var GraphicNovelist = (function(){
// HTML templates for rendering each node type
var TEMPLATES = {
title: '<h1 class="title"><%= body %></h1>',
metadata: '<table class="metadata"><% for(key in metadata){%><tr><th><%= key %>:</th><td><%= metadata[key] %></td></tr><%}%></table>',
page: '<h2 class="page"><%= body %></h2>',
panel: '<p class="panel"><span class="number"><%= metadata._panelPrefix %><%= panel %><%= metadata._panelSuffix %></span><%= body %>',
action: '<p class="action"><%= body %></p>',
dialog: '<dl class="<%= kind %>"><dt class="character"><span class="count"><%= count %> </span><%= character %><%= metadata._characterSuffix %></dt><dd class="body"><% if(parenthetical) {%><span class="parenthetical">(<%= parenthetical %>)</span> <%}%><%= body %></dd></dl>',
empty: ' '
}
// SFX and Dialog are rendered the same way, by default.
TEMPLATES['sfx'] = TEMPLATES['dialog']
// Regular expressions:
// Match text surrounded by whitespace
var TRIM_TEXT = /^[\s]*(.*?)[\s]*$/,
// Page headers: [Page #]
PAGE_HEAD = /^\[[\s]*(.*?)[\s]*\].*$/,
// Page numbers from page headers
PAGE_NUM = /(\d{1,})/,
// Meta keyword args: @NAME : VALUE
META_KWARG = /^@[\s]*(.*?)[\s]*:[\s]*(.*?)[\s]*$/,
// Dialog block: CHARACTER: DIALOG
DIALOG = /^[\s]*([^:]*?)[\s]*:[\s]*(.*)$/,
// Parenthetical: (PARENTHETICAL) DIALOG
PARENTHETICAL = /^.*?\([\s]*(.*?)[\s]*\)[\s]*(.*?)[\s]*$/,
// Panels: panel # - DESCRIPTION
PANEL = /^(?:p|panel)[\s]*([\d]{1,})[\s|\W]*(.*)[\s]*$/i;
// Node class
function Node(kind, prevNode, src, meta) {
// All the attributes that can/will be set...
this.previousNode = prevNode || null;
this.source = src || null;
this.kind = kind || 'empty';
this.body = null;
this.character = null;
this.parenthetical = null;
this.panel = null;
this.metadata = meta;
}
// Simple JavaScript Templating
// John Resig - http://ejohn.org/ - MIT Licensed
var cache = {};
function tmpl(str, data){
// Figure out if we're getting a template, or if we need to
// load the template - and be sure to cache the result.
var fn = !/\W/.test(str) ?
cache[str] = cache[str] || tmpl( TEMPLATES[str] ) : // document.getElementById(str).innerHTML
// Generate a reusable function that will serve as a template
// generator (and which will be cached).
new Function("obj",
"var p=[],print=function(){p.push.apply(p,arguments);};" +
// Introduce the data as local variables using with(){}
"with(obj){p.push('" +
// Convert the template into pure JavaScript
str
.replace(/[\r\t\n]/g, " ")
.split("<%").join("\t")
.replace(/((^|%>)[^\t]*)'/g, "$1\r")
.replace(/\t=(.*?)%>/g, "',$1,'")
.split("\t").join("');")
.split("%>").join("p.push('")
.split("\r").join("\\'")
+ "');}return p.join('');");
// Provide some basic currying to the user
return data ? fn( data ) : fn;
}
function trimWhitespace(text) {
return text.match(TRIM_TEXT)[1];
}
function merge(from, to) {
for(prop in from) {
if(from.hasOwnProperty(prop)) {
to[prop] = from[prop];
}
}
return to;
}
function parseLines(lines, opts) {
var lastNode = null,
lastLine = null,
pageCnt = 0,
dialogCnt = 1,
sfxCnt = 65, // ASCIII VALUE OF "A"
metaData = merge(opts, {
_panelSuffix: ' - ',
_panelPrefix: 'Panel ',
_characterSuffix: ':'
}),
errors = [],
nodes = [];
// Loop through each line of the script...
for (var i=0; i < lines.length; i++) {
var line = lines[i],
firstChar = line[0],
node = null;
// Decypher line type by the first character...
switch(firstChar) {
case '[': // Page Heading
node = new Node('page', lastNode, line, metaData);
node.body = line.match( PAGE_HEAD )[1] // Text within brackets []
pageNumMatch = line.match( PAGE_NUM )
pageCnt++;
node.number = (pageNumMatch) ? pageNumMatch[1] : pageCnt;
dialogCnt = 1;
sfxCnt = 65; // ASCII VALUE OF "A"
nodes.push(node);
lastNode = node;
break;
case '@': // Meta Data
var kwargs = line.match( META_KWARG )
if(kwargs) {
metaData[ kwargs[1] ] = kwargs[2];
if(kwargs[1] == '_characterList') {
metaData[ kwargs[2] ] = "..."
}
}
break;
case ' ': // Dialog or SFX
case "\t":
var dialogMatches = line.match( DIALOG );
if( !dialogMatches ) {
if(lastNode && lastNode.kind == 'dialog') {
lastNode.body += " "+ trimWhitespace(line);
}
} else {
charName = dialogMatches[1];
nodeType = (charName.toUpperCase() == 'SFX') ? 'sfx' : 'dialog';
node = new Node(nodeType, lastNode, line, metaData);
node.character = charName;
dialogBodyMatches = dialogMatches[2].match( PARENTHETICAL );
if(dialogBodyMatches) {
node.parenthetical = dialogBodyMatches[1];
node.body = dialogBodyMatches[2];
} else {
node.body = trimWhitespace(dialogMatches[2]);
}
if( node.kind == 'sfx') {
// SFX are 'numbered' with characters...
node.count = String.fromCharCode( (sfxCnt++) );
} else {
node.count = (dialogCnt++);
}
nodes.push(node);
lastNode = node;
}
break;
default: // Action, Panel, or Empty line
if(typeof firstChar != 'undefined') {
var matches = line.match( PANEL );
if(matches) { // Panel!
node = new Node('panel', lastNode, line, metaData);
node.panel = matches[1]; // Auto-number panels?
node.body = matches[2];
nodes.push(node);
lastNode = node;
} else {
if( lastNode && (lastNode.kind == 'action' || lastNode.kind == 'panel') && (lastLine && lastLine != '') ) {
lastNode.body += ' '+ trimWhitespace(line);
} else {
node = new Node('action', lastNode, line, metaData);
node.body = trimWhitespace(line);
nodes.push(node);
lastNode = node;
}
}
}
}
lastLine = line;
};
// Post processing...
if('_characterList' in metaData) {
var characters = [],
alreadyDef = {};
for (var i=0; i < nodes.length; i++) {
var node = nodes[i];
if(node.kind == 'dialog') {
if(!alreadyDef[node.character]) {
characters.push(node.character);
alreadyDef[node.character] = true;
}
}
};
metaData[metaData['_characterList']] = characters.sort().join(", ");
}
// We need at least one node
if(nodes.length == 0) {
nodes.push(new Node('empty', null, '', metaData) )
}
return nodes;
}
// Renders a node collection
function renderNodes(nodes, opts) {
var html = ['<div class="graphic-novelist">'],
firstNode = nodes[0],
safeMeta = {}
// Render all the 'safe' metadata
for(prop in firstNode.metadata) {
var isTitle = prop.match(/^(title)$/i)
if(isTitle)
html.push( tmpl('title', { body:firstNode.metadata[ isTitle[1] ] }))
else if(prop[0] != '_')
safeMeta[prop] = firstNode.metadata[prop];
}
html.push(tmpl('metadata', {metadata:safeMeta}))
for (var i=0; i < nodes.length; i++) {
var node = nodes[i];
html.push( tmpl(node.kind, node) )
};
html.push("</div>")
return html.join('');
}
// The public API
return {
// Parse text into array of script Nodes
parse: function(text, opts) {
var lines = text.split("\n");
var nodes = parseLines(lines, opts);
return nodes;
},
// Renders array of script Nodes into HTML
renderNodes: function(nodes, opts) {
return renderNodes(nodes, opts);
},
// Parses and renders supplied text, returning HTML
render: function(text, opts) {
// Loop over nodes and generate appropriate HTML...
return GraphicNovelist.renderNodes(GraphicNovelist.parse(text, opts), opts);
},
// Parses, renders, and replaces element's innerHTML with generated HTML
renderTo: function(elemId, text, opts) {
document.getElementById(elemId).innerHTML = GraphicNovelist.render(text, opts);
}
}
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment