こんにちは、freee でエンジニアをしています @tohashi です。
これは世紀末のボストンでモヒカン達と戯れたい欲求に抗いながら書かれた、 freee Engineers Advent Calendar 2015 の21日目の記事です。
今日は freee のプロダクトに欠かせない「CSS組版」と呼ばれる作業と、それを効率化するためのツール CSS-Typesetter についてご紹介します。
CSS組版とは
「バックオフィス最適化」を掲げる freee では会計 freee、給与計算 freee、会社設立 freee、マイナンバー管理 freeeといったプロダクトを展開しています。その中で欠かせないのが、各種申告や手続きのための書類をブラウザ上で確認したり、PDF形式で出力する機能です。例を挙げると、
といったものがあります。
これらの画面は基本的にHTML+CSSで組み、PDF出力時は wkhtmltopdf を使用してサーバーサイドでレンダリングを行っています。HTML+CSSの構成ですがベースとなる書類の画像を置き、その上にテキストの入ったdivタグを(セマンティックという言葉を窓から投げ捨てて)position: absolute
でひたすら愚直に並べています。この、「書類上にテキストボックスを並べていく」作業を社内では組版になぞらえてCSS組版と呼んでいます。CSS組版の工程としては
- CSS(SCSS)を書く
- ブラウザで確認
- デベロッパーコンソールを開き、インスペクタで微調整
- 修正内容をCSSに反映
という手順の繰り返しです。項目数の少ない書類であればこのような方法でもまだ良いかもしれませんが、上の例からもわかる通り我々が関わる書類の大半はそうではありません。この作業は非常に苦痛を伴う上に効率的なものとは言えませんでした。
CSS-Typesetter
2015年にこんな事をしていてはいけないということで、効率化のためのツールを作ることにしました。考えたのはGUI上でテキストブロックの配置・調整を行い、その内容をそのままCSSとして出力することです。CSSを出力する機能を持つツールはいくつか存在しますが、余計な機能が多く成果物がごちゃついたものになったり、一癖あって既存のプロダクトに組み込もうとすると大変だったりと、なかなか思うようにはいきません。そこでCSS組版という機能に特化し、組み込みやすいシンプルなCSSを生成できることを目指しました。そうして作ったのが CSS-Typesetter です。
CSSの自動生成
CSS-Typesetter はフロントエンドのみで完結したWebアプリケーションで、ブラウザ上で矩形を配置・編集しその内容をHTML+CSSとして出力することができます。こちらのデモをご覧ください。
<div class="doc-image">
<div class="text-block text-1">test</div>
</div>
.doc-image {
position: relative;
background-size: 100% auto;
background-image: url(<IMAGE_URL>);
width: 520px;
height: 736px;
.text-block {
position: absolute;
word-wrap: break-word;
transform-origin: 0 0;
}
.text-1 {
left: 176px;
top: 13px;
width: 130px;
height: 20px;
font-size: 16px;
}
}
font-size
, line-height
, letter-spacing
といった各種プロパティも編集するとそのままCSSに反映されます。
編集内容はLocalStorageに保存、またはJSON形式でのインポート/エクスポートが可能です。
実装としてはBabel + React.js + fluxと昨今よくある感じで、 ドラッグ操作には react-draggable を使用、各コンポーネントのstateをそのままCSSのプロパティとして吐き出しています。
<div className="preview-css">
<pre>
{`.${this.props.imageClassName} {
position: relative;
background-size: 100% auto;
background-image: url(<IMAGE_URL>);
width: ${this.props.previewWidth}px;
height: ${this.props.previewHeight}px;
.${this.props.textClassName} {
position: absolute;
word-wrap: break-word;
transform-origin: 0 0;
}
`}
{(() => {
return this.props.texts.map((text, i) => {
let css =` .${text.key} {
left: ${text.x}px;
top: ${text.y}px;
width: ${text.width}px;
height: ${text.height}px;
font-size: ${text.fontSize}px;`;
if (text.scale !== 1) {
css += `transform: scale(${text.scale});`;
}
if (text.lineHeight) {
css += `line-height: ${text.lineHeight}px;`;
}
if (text.letterSpacing) {
css += `letter-spacing: ${text.letterSpacing}px;`;
}
if (text.textAlign != 'left') {
css += `text-align: ${text.textAlign};`;
}
css += '}';
return (
<span key={text.key}>
{css}
</span>
);
});
})()}
{'}'}
</pre>
</div>
undo/redo
エディターには欠かせない機能であるundo/redoも実装しています。
fluxの設計に従いデータフローを単方向にしていたため実装は簡単でした。StoreがActionを受け取るたび、データをまるっと配列に突っ込んでいき、(上限あり)undo/redoアクションに応じてインデックスの位置をずらして取り出すだけです。
const instance = new TextStore();
instance.dispatchToken = Dispatcher.register((action) => {
switch (action.actionType) {
case ActionTypes.UPDATE_TEXT:
updateTexts(action.params);
instance.emitChange();
break;
case ActionTypes.REMOVE_TEXT:
removeText(action.key);
instance.emitChange();
break;
case ActionTypes.UNDO:
historyIdx -= 1;
// 配列から1つ前のデータをコピー
texts = _.cloneDeep(textsHistory[historyIdx]);
instance.emitChange();
return;
case ActionTypes.REDO:
historyIdx += 1;
texts = _.cloneDeep(textsHistory[historyIdx]);
instance.emitChange();
return;
if (TextStore.redoable) {
textsHistory.splice(historyIdx);
}
// undo/redo以外の全てのアクションごとにstoreのデータを配列に保存
textsHistory.push(_.cloneDeep(texts));
historyIdx = textsHistory.length - 1;
});
もっともこの大雑把な方式は小規模なアプリケーションだからできていることで、大規模なアプリケーションになってくると副作用と影響範囲を見定めた上で何を履歴として持っておくかちゃんと決める必要があるでしょう。
矩形の自動検出
さて、これで辛い組版作業も楽に・・・なりませんでした。おおまかに配置はできても、結局細部は手で1pxずつ調整していく必要があり、これではインスペクタでちまちまやっているのとあまり変わりありません。そこでテキストブロックの配置をもう少し自動化しました。
幸いにして多くの入力欄は枠線で囲まれた矩形になっているため、Canvasを使って二値化した上でクリックされたピクセルを中心に矩形を検出、同じ位置・大きさのテキストブロックを置くようにしました。
handleClickCanvas(e) {
const rect = {};
const zoom = this.props.previewWidth / this.props.imageWidth;
const baseIdx = (Math.round((e.pageX - 10) / zoom) + Math.round((e.pageY - 10) / zoom) * this.props.imageWidth) * 4;
// canvasからgetImageDataで取得した書類のピクセルデータ(Uint8ClampedArray)
const data = this.state.imageData.data;
const imageWidth = this.state.imageData.width;
// 矩形の左上となるピクセルデータの位置
const upperLeftIdx = this.scanEdgePoint(data, baseIdx, -4, imageWidth * -4);
// 矩形の右下となるピクセルデータの位置
const lowerRightIdx = this.scanEdgePoint(data, baseIdx, 4, imageWidth * 4);
rect.x = (upperLeftIdx / 4) % imageWidth;
rect.y = Math.floor((upperLeftIdx / 4) / imageWidth);
rect.w = (lowerRightIdx / 4) % imageWidth - rect.x;
rect.h = Math.floor((lowerRightIdx / 4) / imageWidth) - rect.y;
this.props.handleUpdateText({
x: Math.round(rect.x * zoom),
y: Math.round(rect.y * zoom),
width: Math.round(rect.w * zoom),
height: Math.round(rect.h * zoom)
});
}
// 基準点からintervalの方向に、黒のピクセルにぶつかるまで走査
scanEdgePoint(data, baseIdx, intervalX, intervalY) {
let scanning = true;
let edgeIdx = baseIdx;
while (scanning) {
if (data[edgeIdx + intervalX + intervalY] > 0) {
edgeIdx += intervalX + intervalY;
} else if (data[edgeIdx + intervalX] > 0) {
edgeIdx += intervalX;
} else if (data[edgeIdx + intervalY] > 0) {
edgeIdx += intervalY;
} else {
scanning = false;
}
}
return edgeIdx;
}
結構いい感じに矩形が検出できるようになりました。これで様々な様式の書類でも、今までより効率的にCSS組版が進められそうです。
使い方
CSS-Typesetter はgit clone
不要でこちらから使うことができます。Web上で書類を表現したい、画像の上に文字を配置したいという方はぜひお試しください。まだまだ未完成なので、もし試してみて気になったところなどあれば issue や PR いただけると嬉しいです。
- 出力するクラス名やmarginなどの詳細な設定
- 指定したディレクトリにCSSファイルを出力するgulpプラグイン
- CSSをパースして直接インポート可能に
- Reactコンポーネント化
- もう少しマシなUI
などなど、これから機能改善していく予定ですのでよろしくお願いします。
宣伝
freeeでは煩雑な作業を効率化していきたいフロントエンドエンジニアを募集しています!
明日はデータモデリングの雄、freeeエンジニアのゴッドファーザー @yebihara の登場です。お楽しみに!