책을 짜는 엔진을 만드는 팀
원고에서 지면으로
원고를 받아 줄과 쪽을 정하는 엔진을 만듭니다. 문단 줄바꿈과 한중일 금칙은 표준 라이브러리에서 가져옵니다. 같은 줄바꿈 코드를 브라우저 미리보기와 인쇄용 조판에서 함께 씁니다.
std.typeset 레퍼런스 → (영어)토파즈 6.0
토파즈는 조판을 내장한 범용 프로그래밍 언어입니다. 같은 일을 하는 코드는 누가 써도 같은 모양입니다.
6.0 매뉴얼은 영어로 읽을 수 있습니다.
591규칙15장340API, 모듈 19개109진단1,332예제
투어
다른 언어를 아신다면 이 순서로 읽어 보세요.
import std.ioimport std.bigint { BigInt }let 쪽표시 = ["12", "표지", "14"]let 번호 = 쪽표시.map((글) => match (BigInt.parse(글, 10)) { case Some(수) => 수.toInt() case None() => None()})io.writeLine("{번호}")[Some(12), None, Some(14)]
map은 목록 뒤에 씁니다. BigInt.parse로 각 문자열을 읽고 toInt로 정수를 바꿉니다. ‘표지’가 있던 자리에는 None이 남습니다.
누가, 무엇을
책을 짜는 엔진을 만드는 팀
원고를 받아 줄과 쪽을 정하는 엔진을 만듭니다. 문단 줄바꿈과 한중일 금칙은 표준 라이브러리에서 가져옵니다. 같은 줄바꿈 코드를 브라우저 미리보기와 인쇄용 조판에서 함께 씁니다.
std.typeset 레퍼런스 → (영어)출판사마다 규칙이 다른 팀
장을 어느 쪽에서 시작할지, 외톨이 줄을 몇 줄까지 둘지는 출판사와 책마다 다릅니다. 이런 판단은 리스펙스 규칙 파일에 두고 줄을 나누는 코드는 여러 책에 함께 씁니다. 교재나 보고서처럼 고객마다 서식이 바뀌어도 앱 코드는 그대로입니다.
리스펙스 규칙 부르기 → (영어)웹 도구를 만드는 팀
JSON 검사처럼 입력을 다루는 함수를 토파즈로 쓰고 브라우저 패키지로 빌드합니다. 기존 웹 앱에서는 JavaScript로 부릅니다. 제니앱의 브라우저 도구들도 이렇게 만들었습니다.
JavaScript에서 부르기 → (영어)std.typeset
토파즈 6.0의 std.typeset은 문단을 줄로 나누는 표준 모듈입니다. 아래 프로그램은 한국어 문장 하나를 한글 열 자 너비로 나눕니다. 문단 전체를 보고 나누면 마지막 줄에 낱말 하나만 남지 않습니다.
import std.ioimport std.typeset { BreakCluster, BreakPolicy, BreakMode, KinsokuRules, BreakOpportunity, Paragraph, Item, Box, Penalty, BreakRule, DistanceGrid, CostModel, LineGeometry, JustificationPolicy, JustificationMode, KnuthPlassPolicy, ParagraphPlan, classifyBreaks, breakParagraph,}let 문장 = "문단 전체를 보고 줄을 나누면 마지막 줄이 짧게 남지 않고 줄마다 길이가 고르게 됩니다."let 글자들 = 문장.scalars().map(글자)let 결과 = match (classifyBreaks(글자들, 한국어규칙(글자들))) { case Ok(자리들) => breakParagraph(문단(글자들, 자리들)) case Err(오류) => Err(오류)}match (결과) { case Ok(나눔) => { io.writeLine("문단 전체를 보고 나눔\n{줄글(글자들, 나눔.optimized)}\n") io.writeLine("앞에서부터 채움\n{줄글(글자들, 나눔.greedy)}") } case Err(오류) => io.writeLine("{오류}")}문단 전체를 보고 나눔 문단 전체를 보고 줄을 나누면 마지막 줄이 짧게 남지 않고 줄마다 길이가 고르게 됩니다. 앞에서부터 채움 문단 전체를 보고 줄을 나누면 마지막 줄이 짧게 남지 않고 줄마다 길이가 고르게 됩니다.
프로그램의 핵심 부분입니다. 글자 너비를 정하고 문단을 만드는 도움 함수는 전체 소스에 있습니다.
import std.ioimport std.typeset { BreakCluster, BreakPolicy, BreakMode, KinsokuRules, BreakOpportunity, Paragraph, Item, Box, Penalty, BreakRule, DistanceGrid, CostModel, LineGeometry, JustificationPolicy, JustificationMode, KnuthPlassPolicy, ParagraphPlan, classifyBreaks, breakParagraph,}const 줄너비 = 100pt// 한글은 10pt, 빈칸은 2.5pt 너비로 둡니다.function 글자(낱자: string) -> BreakCluster { BreakCluster { text: 낱자, advance: if (낱자 == " ") { 2.5pt } else { 10pt }, trackingAfter: 0pt }}// 유니코드 줄바꿈 규칙(UAX #14)에 한국어 금칙(KLReq)을 더합니다. 줄에 들어가는 낱말은 쪼개지 않습니다.function 한국어규칙(글자들: Array<BreakCluster>) -> BreakPolicy { BreakPolicy { mode: BreakMode.Word(), rules: KinsokuRules.Unicode16Ko(), forbidBefore: [], forbidAfter: [], boundaryAllowed: 글자들.map((c: BreakCluster) => true), generatedHyphenEnds: [], protectFittingLatin: false, preserveShortWords: false, narrowestWordTarget: 줄너비, bodyWordTarget: 줄너비 }}// 글자마다 상자 하나, 줄을 나눌 수 있는 자리마다 벌점 0을 둡니다. 마지막 자리에서는 반드시 줄을 끝냅니다.function 문단(글자들: Array<BreakCluster>, 자리들: Array<BreakOpportunity>) -> Paragraph { let 상자들: Array<Item> = [for 번호 in (0..<글자들.length) => { let 낱자 = 글자들[번호] Item.BoxItem(Box { unitIndex: 번호, text: 낱자.text, width: 낱자.advance, trackingAfter: 0pt }) }] let 벌점들: Array<Item> = [for 자리 in (자리들) => { let 끝 = 자리.unitEnd == 글자들.length Item.PenaltyItem(Penalty { unitEnd: 자리.unitEnd, width: 0pt, kind: 자리.kind, flagged: false, rule: if (끝) { BreakRule.Forced() } else { BreakRule.Allowed(0) } }) }] Paragraph { grid: DistanceGrid.MilliPoint(), items: [...상자들, ...벌점들], costModel: CostModel.FixedPoint(), geometry: LineGeometry { bodyTarget: 줄너비, firstTarget: 줄너비, dropCapSpan: 0, dropCapInset: 0pt }, justification: JustificationPolicy { mode: JustificationMode.Unjustified(), minGapCount: 0, minLineWidthRatioM: 0, minFinalLineWidthRatioM: None(), maxAdjustment: 0pt, maxGapAdjustment: 0pt, maxGapRatioR: 0, latinSpacing: None(), cjkStretchRatio: 0.0 }, knuthPlass: KnuthPlassPolicy { toleranceR: 0, linePenalty: 0, fitnessDemerit: 0, flaggedDemerit: 0, finalFlaggedDemerit: 0 } }}// 줄마다 글자를 이어 붙이고 줄 끝 빈칸은 뺍니다.function 줄글(글자들: Array<BreakCluster>, 계획: ParagraphPlan) -> string { let mut 글 = "" for 줄번호 in (0..<계획.lines.length) { if (줄번호 > 0) { 글 = 글 + "\n" } let 줄 = 계획.lines[줄번호] for 글자번호 in (줄.start..<줄.trimmedEndExclusive) { 글 = 글 + 글자들[글자번호].text } } 글}let 문장 = "문단 전체를 보고 줄을 나누면 마지막 줄이 짧게 남지 않고 줄마다 길이가 고르게 됩니다."let 글자들 = 문장.scalars().map(글자)let 결과 = match (classifyBreaks(글자들, 한국어규칙(글자들))) { case Ok(자리들) => breakParagraph(문단(글자들, 자리들)) case Err(오류) => Err(오류)}match (결과) { case Ok(나눔) => { io.writeLine("문단 전체를 보고 나눔\n{줄글(글자들, 나눔.optimized)}\n") io.writeLine("앞에서부터 채움\n{줄글(글자들, 나눔.greedy)}") } case Err(오류) => io.writeLine("{오류}")}문단 전체를 보고 나눔 문단 전체를 보고 줄을 나누면 마지막 줄이 짧게 남지 않고 줄마다 길이가 고르게 됩니다. 앞에서부터 채움 문단 전체를 보고 줄을 나누면 마지막 줄이 짧게 남지 않고 줄마다 길이가 고르게 됩니다.
문단 전체를 보고 고른 줄 나눔과 앞에서부터 채운 탐욕 줄 나눔을 함께 받습니다. 비용 모델은 Knuth-Plass와 고정소수점 모델 가운데 고릅니다.
유니코드 16.0의 줄바꿈 규칙(UAX #14)으로 줄을 나눌 수 있는 자리를 찾습니다.
한국어(KLReq), 일본어(JLReq), 중국어(CLReq) 규칙을 더하면 여는 괄호는 줄 끝에, 닫는 괄호와 마침표는 줄 머리에 오지 않습니다. 일본어 규칙은 JLReq의 문자 분류를 따릅니다.
유니코드 동아시아 문자 폭 정보로 한중일 글자 옆 따옴표와 괄호에서 줄을 나눌지 정합니다.
양쪽 맞춤에서 라틴 낱말 사이는 얼마나 늘이고 줄일지, 한중일 글자 사이는 얼마나 늘일지 정합니다.
12pt, 3mm, 1cm, 1in, 1em 같은 길이를 코드에 그대로 쓰고 단위를 바꿉니다.
아틀라스
아틀라스는 원고를 인쇄용 책 지면으로 조판합니다. 아틀라스로 조판한 책의 한 쪽과 금칙 함수를 6.0 문법으로 옮긴 표본을 함께 실었습니다. 소스의 함수 이름에는 한국어 조판 용어를 그대로 씁니다.

function 다음은라틴공백아님(다음: Option<string>) -> bool { match (다음) { case Some(텍스트) => !라틴공백인가(텍스트) case None() => true }}function 다음은줄머리금지없음(다음: Option<string>) -> bool { match (다음) { case Some(텍스트) => !일부줄머리금지(텍스트) case None() => true }}false true false true
아틀라스의 금칙 함수 두 개를 토파즈 6.0 문법으로 옮겼습니다. 전체 표본에는 동작을 보여 주는 짧은 도움 함수와 입력을 더했습니다.
import std.io// Small sample policies for the two predicates below.function 라틴공백인가(텍스트: string) -> bool { 텍스트 == " "}function 일부줄머리금지(텍스트: string) -> bool { 텍스트 == "."}function 다음은라틴공백아님(다음: Option<string>) -> bool { match (다음) { case Some(텍스트) => !라틴공백인가(텍스트) case None() => true }}function 다음은줄머리금지없음(다음: Option<string>) -> bool { match (다음) { case Some(텍스트) => !일부줄머리금지(텍스트) case None() => true }}io.writeLine("{다음은라틴공백아님(Some(" "))} {다음은라틴공백아님(None())}")io.writeLine("{다음은줄머리금지없음(Some("."))} {다음은줄머리금지없음(None())}")false true false true
토파즈가 조판에 강한 이유
자주 하는 일은 쓰는 방법이 하나로 정해져 있습니다. 사람이 짜든 에이전트가 짜든 코드가 같은 모양이 되므로, 리뷰할 때는 코드가 하는 일만 보면 됩니다.
수를 문자열로 바꾸는 일과 컬렉션 연산은 값의 메서드로 씁니다. 배열에는 map을, 수에는 toString을 씁니다. 서식은 topaz fmt로 맞춥니다.
컬렉션 연산 (영어)값이 없을 수 있으면 Option, 실패할 수 있으면 Result를 돌려주고 정리 작업은 defer로 씁니다. 문단 끝이라 다음 글자가 없으면 금칙 함수는 오류가 아니라 None을 받습니다.
null·Option·Result·실행 오류 (영어)화면 출력도 파일도 표준 모듈을 불러와 씁니다. 검토하는 사람은 import를 보고 입출력이 일어나는 자리를 바로 찾습니다.
표준 라이브러리 개요 (영어)줄바꿈 종류처럼 경우가 정해진 목록을 열거형으로 두면 match가 모든 경우를 다루는지 컴파일러가 확인합니다. 새 경우를 더하면 그 경우를 아직 다루지 않은 match를 빌드가 알려 줍니다.
패턴과 제어 흐름 (영어)정수는 64비트이고, 값이 넘치면 틀린 값으로 계속하지 않고 그 자리에서 멈춥니다. 줄 길이와 배치 비용을 정수로 셈하면 넘침이 조용한 오차로 남지 않습니다.
런타임 동작 (영어)직접 실행, 네이티브 실행 파일, 브라우저 패키지가 같은 언어 규칙을 따릅니다. 그래서 화면 미리보기와 인쇄용 조판이 같은 자리에서 줄을 바꿉니다.
실행 대상 (영어)리스펙스
줄 나누기 같은 계산은 토파즈 코드가 합니다. 장을 어느 쪽에서 시작할지, 외톨이 줄을 몇 줄까지 둘지처럼 출판사와 책마다 달라지는 판단은 리스펙스 규칙으로 따로 씁니다. 토파즈 6.0은 std.lispex로 리스펙스 2.0 규칙을 실행하며 준비, 값 변환, 평가의 한도를 따로 정합니다. 규칙 소스를 바꾸고 다시 준비하면 앱 코드를 고치지 않고 같은 원고를 다른 스타일로 짤 수 있습니다.
(define rule (lambda (input) (let ((min-lines 2)) (if (< input min-lines) "다음-쪽으로" "그대로"))))lispex-ko.scm 전체
import std.ioimport std.bigint { BigInt }import std.lispex { LispexPackage, LispexHostLimits, LispexPreparationLimits, LispexSource, LispexSourceBundle, PreparedLispexRule, LispexDatumView, LispexValue, applicationPackage, defaultLimits, prepare, valueFromDatum, valueView, evaluate,}export function main(args: Array<string>, stdin: string) -> Result<int, string> { let 응용 = packageOf()? let 한도 = defaultLimits() let 규칙 = ruleFrom(응용, stdin, 한도.preparation)? for 남은줄 in ([1, 2]) { let 입력 = numberOf(응용, "{남은줄}", 한도.host)? let 평가 = match (evaluate(규칙, 입력, 한도.evaluation)) { case Ok(값) => 값 case Err(_) => { return Err("The evaluation could not complete.") } } match (평가.settlement) { case Complete(결과) => io.writeLine("남은 줄 {남은줄} {textOf(결과, 한도.host)?}") case SemanticFailure(_) => { return Err("The rule returned a language failure.") } case LimitExhaustion(_) => { return Err("The rule reached its evaluation limit.") } } } return Ok(0)}남은 줄 1 다음-쪽으로 남은 줄 2 그대로
lispex-ko.tpz의 핵심 부분입니다. 준비와 값 변환을 위한 도움 함수는 전체 소스에 있습니다.
import std.ioimport std.bigint { BigInt }import std.lispex { LispexPackage, LispexHostLimits, LispexPreparationLimits, LispexSource, LispexSourceBundle, PreparedLispexRule, LispexDatumView, LispexValue, applicationPackage, defaultLimits, prepare, valueFromDatum, valueView, evaluate,}function packageOf() -> Result<LispexPackage, string> { match (applicationPackage()) { case Ok(value) => Ok(value) case Err(_) => Err("The application package was not admitted.") }}function ruleFrom(application: LispexPackage, source: string, limits: LispexPreparationLimits) -> Result<PreparedLispexRule, string> { let bundle = LispexSourceBundle { entryModule: "widow-lines.scm", entryBinding: "rule", sources: [LispexSource { name: "widow-lines.scm", bytes: source.encodeUtf8() }], } match (prepare(application, bundle, limits)) { case Ok(rule) => Ok(rule) case Err(_) => Err("The rule could not be prepared.") }}function numberOf(application: LispexPackage, text: string, limits: LispexHostLimits) -> Result<LispexValue, string> { let numerator = match (BigInt.parse(text, 10)) { case Some(value) => value case None() => { return Err("The input must be an integer.") } } let denominator = match (BigInt.parse("1", 10)) { case Some(value) => value case None() => { return Err("The denominator must be an integer.") } } match (valueFromDatum(application, LispexDatumView.Number(numerator, denominator), limits)) { case Ok(value) => Ok(value) case Err(_) => Err("The input value was not admitted.") }}function textOf(value: LispexValue, limits: LispexHostLimits) -> Result<string, string> { match (valueView(value, limits)) { case Ok(Text(text)) => Ok(text) case _ => Err("The rule must return text.") }}export function main(args: Array<string>, stdin: string) -> Result<int, string> { let 응용 = packageOf()? let 한도 = defaultLimits() let 규칙 = ruleFrom(응용, stdin, 한도.preparation)? for 남은줄 in ([1, 2]) { let 입력 = numberOf(응용, "{남은줄}", 한도.host)? let 평가 = match (evaluate(규칙, 입력, 한도.evaluation)) { case Ok(값) => 값 case Err(_) => { return Err("The evaluation could not complete.") } } match (평가.settlement) { case Complete(결과) => io.writeLine("남은 줄 {남은줄} {textOf(결과, 한도.host)?}") case SemanticFailure(_) => { return Err("The rule returned a language failure.") } case LimitExhaustion(_) => { return Err("The rule reached its evaluation limit.") } } } return Ok(0)}남은 줄 1 다음-쪽으로 남은 줄 2 그대로
prepare로 규칙을 준비하고 evaluate로 실행합니다.
리스펙스 보기토파즈로 만든 프로그램
토파즈 컴파일러의 핵심은 토파즈로 썼습니다. 브라우저 도구도 같은 언어로 씁니다.
function assignmentRoot(node: SyntaxNode) -> Option<SyntaxNode> { if (node.kind == "expression/identifier") { return Some(node) } match (child(node, "object")) { case Some(object) => assignmentRoot(object) case None() => None() }}expression/identifier None
compiler/crates/topaz_self_frontend/topaz/checker/expression_core.tpz의 대입 대상 탐색 함수를 토파즈 6.0 문법으로 옮겼습니다.
import std.io// These small inputs exercise the traversal independently of compiler internals.record SyntaxNode { kind: string }function child(node: SyntaxNode, name: string) -> Option<SyntaxNode> { if (node.kind == "expression/member" && name == "object") { Some(SyntaxNode { kind: "expression/identifier" }) } else { None() }}function assignmentRoot(node: SyntaxNode) -> Option<SyntaxNode> { if (node.kind == "expression/identifier") { return Some(node) } match (child(node, "object")) { case Some(object) => assignmentRoot(object) case None() => None() }}match (assignmentRoot(SyntaxNode { kind: "expression/member" })) { case Some(root) => io.writeLine(root.kind) case None() => io.writeLine("missing")}io.writeLine("{assignmentRoot(SyntaxNode { kind: "expression/literal" })}")expression/identifier None
이름과 타입을 검사하고 Rust 코드를 만드는 일을 토파즈 코드가 합니다. topaz check로 프로그램을 검사하고 topaz build로 실행 파일을 만듭니다.
토파즈로 만든 프로그램제니앱의 JSON 포매터는 토파즈로 쓴 파서로 입력을 정리하고, 닫는 괄호가 빠지면 오류가 난 줄과 열을 알려 줍니다.
JavaScript에서 부르기 (영어)토파즈 살펴보기
토파즈가 어떤 문제에 알맞고 코드가 어떤 모습인지 첫 프로그램을 작성하기 전에 살펴봅니다.
토파즈 알아보기 (영어)작은 프로그램 하나로 시작해 검사와 테스트를 거친 두 모듈 애플리케이션까지 순서대로 완성합니다.
학습 시작하기 (영어)std.typeset으로 문단을 줄로 나눕니다. 12pt나 3mm 같은 너비, 언어별 줄바꿈 규칙, 양쪽 맞춤 줄의 간격을 정합니다.
조판 레퍼런스 열기 (영어)문법 지도에서 시작해 언어 규칙, 표준 라이브러리, 도구를 찾습니다.
문법 지도 열기 (영어)