표준 라이브러리

조판

길이 단위, 글자 너비, 줄바꿈 기회, 금칙, 탐욕 줄바꿈과 최적 줄바꿈, 비용과 간격을 예제로 익히고 공개 API를 찾습니다.

std.typeset으로 문단을 주어진 폭의 줄로 나누고 줄마다 늘이거나 줄일 간격을 계산합니다. 한국어·중국어·일본어·라틴 문자가 섞인 문단도 유니코드 줄바꿈 규칙과 언어별 금칙대로 끊습니다. 금칙은 닫는 괄호나 마침표처럼 줄 첫머리에 오면 안 되는 글자, 여는 괄호처럼 줄 끝에 오면 안 되는 글자를 정한 조판 규칙입니다.

이 모듈은 5.21 언어 모드에서 import std.typeset으로 가져옵니다. 예제를 실행하려면 첫 프로그램에서 토파즈를 설치하세요. 다른 표준 모듈은 표준 라이브러리 개요에서 찾습니다.

문단 하나를 줄로 나누기

다음 코드를 typeset-paragraph.tpz로 저장하세요. 너비 단위는 밀리포인트(1pt의 1000분의 1)입니다. 10pt 글꼴을 가정해 코드 포인트가 4352(U+1100) 이상인 글자는 10,000, 그 밖의 글자는 5,000, 공백은 2,500으로 잡았습니다. 줄 폭은 100,000(100pt)입니다.

TOPAZ
import std.io
import std.typeset

function 너비(글자: string) -> int {
  if 글자 == " " { return 2500 }
  match 글자.codePointAt(0) {
    case Some(코드값) => if 코드값 >= 4352 { 10000 } else { 5000 };
    case None => 0
  }
}

function 줄나누기(글: string, 폭: int) -> Result<Array<string>, typeset.TypesetError> {
  let 글자들 = 글.scalars()
  let 묶음들 = 글자들.map(글자 => typeset.BreakCluster { text: 글자, advance: 너비(글자), trackingAfter: 0 })
  let 기회들 = typeset.classifyBreaks(묶음들, typeset.BreakPolicy {
    mode: typeset.BreakMode.Word, rules: typeset.KinsokuRules.Unicode16Ko,
    forbidBefore: [], forbidAfter: [], boundaryAllowed: 글자들.map(글자 => true),
    generatedHyphenEnds: [], protectFittingLatin: false, preserveShortWords: false,
    narrowestWordTarget: 폭, bodyWordTarget: 폭
  })?

  let mut 항목들: Array<typeset.Item> = []
  for 번호 in 0..<글자들.length {
    항목들.push(typeset.Item.BoxItem(typeset.Box { unitIndex: 번호, text: 글자들[번호], width: 너비(글자들[번호]), trackingAfter: 0 }))
  }
  for 기회 in 기회들 {
    let 규칙 = if 기회.kind == typeset.BreakKind.ForcedEnd { typeset.BreakRule.Forced } else { typeset.BreakRule.Allowed(0) }
    항목들.push(typeset.Item.PenaltyItem(typeset.Penalty { unitEnd: 기회.unitEnd, width: 0, rule: 규칙, kind: 기회.kind, flagged: false }))
  }

  let 결과 = typeset.breakParagraph(typeset.Paragraph {
    grid: typeset.DistanceGrid.MilliPoint,
    items: 항목들,
    geometry: typeset.LineGeometry { bodyTarget: 폭, firstTarget: 폭, dropCapSpan: 0, dropCapInset: 0 },
    justification: typeset.JustificationPolicy {
      mode: typeset.JustificationMode.Unjustified, minGapCount: 0, minLineWidthRatioM: 0,
      minFinalLineWidthRatioM: None, maxAdjustment: 0, maxGapAdjustment: 0, maxGapRatioR: 0,
      latinSpacing: None, cjkStretchRatio: 0.0
    },
    costModel: typeset.CostModel.FixedPoint,
    knuthPlass: typeset.KnuthPlassPolicy { toleranceR: 200000000, linePenalty: 10, fitnessDemerit: 10000, flaggedDemerit: 10000, finalFlaggedDemerit: 5000 }
  })?
  Ok(결과.optimized.lines.map(줄 => 글자들.slice(줄.start, 줄.trimmedEndExclusive).join("")))
}

let 글 = "토파즈는 책과 문서를 조판하는 언어입니다. 한국어와 English가 섞인 문단도 규칙대로 나눕니다."
match 줄나누기(글, 100000) {
  case Ok(줄들) => { for 줄 in 줄들 { io.writeLine(줄) } };
  case Err(오류) => io.writeLine("{오류}")
}

파일을 저장한 폴더에서 topaz run typeset-paragraph.tpz를 실행하세요.

출력
토파즈는 책과 문서를
조판하는 언어입니다.
한국어와 English가
섞인 문단도
규칙대로 나눕니다.

프로그램은 세 단계로 줄을 나눕니다.

  1. classifyBreaks는 글자 묶음(BreakCluster)마다 그 뒤에서 줄을 바꿀 수 있는지 판정합니다. 결과는 줄바꿈 기회(BreakOpportunity) 목록입니다.
  2. 글자마다 상자(Box)를, 줄바꿈 기회마다 페널티(Penalty)를 만들어 문단(Paragraph)에 넣습니다. 문단 끝의 ForcedEnd는 반드시 끊는 자리이므로 BreakRule.Forced로 바꿉니다.
  3. breakParagraph가 줄 계획을 세웁니다. 줄(PlanLine)마다 단위 위치 start부터 trimmedEndExclusive 직전까지가 화면에 남는 글자입니다.

BreakMode.Word는 줄 폭에 들어가는 한국어 어절을 쪼개지 않으므로 줄이 어절 경계에서만 바뀝니다. breakParagraph는 최적 계획 optimized와 탐욕 계획 greedy를 함께 돌려줍니다. 두 계획의 차이는 아래 탐욕 줄바꿈과 최적 줄바꿈 절에서 봅니다.

길이와 단위

조판 치수는 12pt, 3mm, 1cm, 1in, 1em 같은 길이 값으로 씁니다. 단위가 타입에 들어 있어 148mm는 Length<Mm>, 10pt는 Length<Pt>입니다. 같은 단위끼리만 더하고 뺄 수 있습니다. 90mm + 10pt처럼 단위를 섞으면 검사 단계에서 TPZ5001로 거부하므로 먼저 변환하세요.

TOPAZ
import std.io
import std.typeset

let 쪽 = 148mm
let 여백 = 20mm
let 단 = 쪽 - 여백 * 2
let 글자크기 = 10pt

io.writeLine("{단}")
io.writeLine("{단.toPt().toMilliPoints()}")
io.writeLine("{1em.resolve(글자크기).toMilliPoints()}")
io.writeLine("{typeset.fromMilliPoints(10000) == 글자크기}")
출력
Length<Mm>(20063304)
306142
10000
true
  • 148mm - 20mm * 2는 Length<Mm> 값입니다. 출력의 Length<Mm>(20063304)는 내부 값인 스케일드 포인트를 보여 줍니다. 1pt는 65,536 스케일드 포인트입니다.
  • toPt()는 같은 길이를 포인트 단위로 바꾸고 toMilliPoints()는 가장 가까운 정수 밀리포인트를 돌려줍니다. 108mm는 306,142 밀리포인트입니다.
  • 1em은 글꼴 크기를 기준으로 하는 상대 길이입니다. resolve에 글꼴 크기를 넘기면 포인트 값을 얻습니다.
  • typeset.fromMilliPoints와 typeset.fromScaledPoints는 정수를 다시 Length<Pt>로 만듭니다.

문단 API의 너비와 폭은 정수입니다. Paragraph.grid에 그 정수의 단위를 적습니다. DistanceGrid.MilliPoint는 밀리포인트, DistanceGrid.ScaledPoint는 스케일드 포인트입니다. CostModel.FixedPoint는 MilliPoint만 받습니다.

글자 너비 재기

std.typeset은 글꼴을 열거나 파일을 읽지 않습니다. 프로그램이 글꼴에서 글자 너비를 재어 BreakCluster.advance와 Box.width로 넘깁니다. 자간을 넣는다면 너비에 글자 뒤 자간까지 포함하고 그 자간을 trackingAfter에 따로 적습니다.

TOPAZ
import std.io
import std.typeset

let 항목들: Array<typeset.Item> = [
  typeset.Item.BoxItem(typeset.Box { unitIndex: 0, text: "가", width: 10200, trackingAfter: 200 }),
  typeset.Item.BoxItem(typeset.Box { unitIndex: 1, text: "나", width: 10200, trackingAfter: 200 }),
  typeset.Item.BoxItem(typeset.Box { unitIndex: 2, text: " ", width: 2700, trackingAfter: 200 }),
  typeset.Item.PenaltyItem(typeset.Penalty { unitEnd: 3, width: 0, rule: typeset.BreakRule.Forced, kind: typeset.BreakKind.ForcedEnd, flagged: false })
]
let 문단 = typeset.Paragraph {
  grid: typeset.DistanceGrid.MilliPoint,
  items: 항목들,
  geometry: typeset.LineGeometry { bodyTarget: 100000, firstTarget: 100000, dropCapSpan: 0, dropCapInset: 0 },
  justification: typeset.JustificationPolicy {
    mode: typeset.JustificationMode.Unjustified, minGapCount: 0, minLineWidthRatioM: 0,
    minFinalLineWidthRatioM: None, maxAdjustment: 0, maxGapAdjustment: 0, maxGapRatioR: 0,
    latinSpacing: None, cjkStretchRatio: 0.0
  },
  costModel: typeset.CostModel.FixedPoint,
  knuthPlass: typeset.KnuthPlassPolicy { toleranceR: 200000000, linePenalty: 10, fitnessDemerit: 10000, flaggedDemerit: 10000, finalFlaggedDemerit: 5000 }
}

match typeset.breakParagraph(문단) {
  case Ok(결과) => {
    let 줄 = 결과.optimized.lines[0]
    io.writeLine("raw {줄.endExclusive} {줄.rawTextWidth}")
    io.writeLine("trimmed {줄.trimmedEndExclusive} {줄.trimmedTextWidth}")
    io.writeLine("scalars {줄.displayTextScalarLength}")
  };
  case Err(오류) => io.writeLine("{오류}")
}
출력
raw 3 22900
trimmed 2 20200
scalars 2

줄 너비는 줄의 마지막 단위 뒤 자간을 한 번 뺍니다. rawTextWidth는 끝 공백까지 넣은 너비이며 10,200 + 10,200 + 2,700 − 200 = 22,900입니다. trimmedTextWidth는 끝 공백을 빼고 나의 자간을 뺀 20,200입니다. trimmedEndExclusive는 공백 앞 위치 2이고 displayTextScalarLength는 화면에 남는 스칼라 수 2입니다. 끝 공백으로 보는 글자는 U+0020 공백과 U+00A0 줄바꿈 없는 공백입니다.

줄바꿈 기회 (UAX #14)

UAX #14는 유니코드 표준의 줄바꿈 알고리즘입니다. 글자마다 줄바꿈 분류를 매기고 두 글자 사이에서 줄을 바꿔도 되는지 정합니다. classifyBreaks는 유니코드 16.0.0 데이터와 UAX #14 개정 53판의 규칙 LB1~LB31을 적용한 뒤 로케일 금칙을 더합니다. 다음 예제는 줄을 바꿀 수 있는 자리에 |를 넣어 보여 줍니다.

TOPAZ
import std.io
import std.typeset

function 표시(글: string, 규칙집합: typeset.KinsokuRules, 방식: typeset.BreakMode) -> string {
  let 글자들 = 글.scalars()
  let 묶음들 = 글자들.map(글자 => typeset.BreakCluster { text: 글자, advance: 10000, trackingAfter: 0 })
  let 정책 = typeset.BreakPolicy {
    mode: 방식, rules: 규칙집합, forbidBefore: [], forbidAfter: [],
    boundaryAllowed: 글자들.map(글자 => true), generatedHyphenEnds: [],
    protectFittingLatin: false, preserveShortWords: false,
    narrowestWordTarget: 100000, bodyWordTarget: 100000
  }
  match typeset.classifyBreaks(묶음들, 정책) {
    case Ok(기회들) => {
      let 끝들 = 기회들.map(기회 => 기회.unitEnd)
      let mut 출력 = ""
      for 번호 in 0..<글자들.length {
        출력 = "{출력}{글자들[번호]}"
        if 번호 + 1 < 글자들.length && 끝들.indexOf(번호 + 1) != None { 출력 = "{출력}|" }
      }
      출력
    };
    case Err(오류) => "{오류}"
  }
}

let 한국어 = typeset.KinsokuRules.Unicode16Ko
for 글 in ["Hello, world! It's 3.14.", "e.g. well-known", "equals .35 cents", "조판은 (보기처럼) 글자를 나눕니다."] {
  io.writeLine(표시(글, 한국어, typeset.BreakMode.Character))
}
io.writeLine(표시("조판은 (보기처럼) 글자를 나눕니다.", 한국어, typeset.BreakMode.Word))
출력
Hello, |world! |It's |3.14.
e.g. |well-|known
equals .35 |cents
조|판|은 |(보|기|처|럼) |글|자|를 |나|눕|니|다.
조판은 |(보기처럼) |글자를 |나눕니다.
  • 공백 뒤와 하이픈 뒤에서 끊을 수 있습니다. 3.14 같은 숫자 안, 닫는 문장부호 앞, 여는 괄호 뒤에서는 끊지 않습니다.
  • UAX #14만 보면 .35 앞도 줄바꿈 기회입니다. Unicode16Ko는 마침표를 줄 첫머리에 두지 않으므로 그 자리를 뺍니다.
  • BreakMode.Character는 한글 음절 사이마다 끊을 수 있습니다. BreakMode.Word는 너비가 bodyWordTarget을 넘지 않는 한국어 어절을 쪼개지 않습니다.

줄바꿈 기회에는 종류(BreakKind)가 붙습니다. 공백 뒤는 LatinSpace, 하이픈 뒤는 LatinHyphen, 닫는 문장부호 뒤는 ClosingPunctuation, 동아시아 글자 뒤는 KoreanCluster입니다. 문단 끝과 줄바꿈 문자 뒤는 반드시 끊는 ForcedEnd입니다. 어디에도 들지 않는 자리는 HardFallback입니다. FixedPoint 비용 모델은 이 자리에서 끊은 줄에 50,000B를 더합니다(B는 1,000,000). KnuthPlass 모델은 줄바꿈 종류를 비용에 쓰지 않습니다.

동아시아 문자와 JLREQ 분류

한글·한자·가나는 폭이 넓은 동아시아 문자입니다. 이런 글자 사이에서는 공백이 없어도 줄을 바꿀 수 있습니다. classifyBreaks는 유니코드 East_Asian_Width 속성이 전각(F), 넓은 문자(W), 반각(H)인 글자를 동아시아 글자로 봅니다.

JLREQ는 W3C가 펴낸 일본어 조판 요건 문서입니다. 문장부호와 가나를 여는 괄호, 닫는 괄호, 작은 가나 같은 분류로 나누고 분류마다 줄 첫머리와 끝에 올 수 있는지 정합니다. Unicode16Ja는 JLREQ 3.1.7~3.1.10의 엄격한 금칙과 부록 A의 분류표를 씁니다.

TOPAZ
import std.io
import std.typeset

function 표시(글: string, 규칙집합: typeset.KinsokuRules, 방식: typeset.BreakMode) -> string {
  let 글자들 = 글.scalars()
  let 묶음들 = 글자들.map(글자 => typeset.BreakCluster { text: 글자, advance: 10000, trackingAfter: 0 })
  let 정책 = typeset.BreakPolicy {
    mode: 방식, rules: 규칙집합, forbidBefore: [], forbidAfter: [],
    boundaryAllowed: 글자들.map(글자 => true), generatedHyphenEnds: [],
    protectFittingLatin: false, preserveShortWords: false,
    narrowestWordTarget: 100000, bodyWordTarget: 100000
  }
  match typeset.classifyBreaks(묶음들, 정책) {
    case Ok(기회들) => {
      let 끝들 = 기회들.map(기회 => 기회.unitEnd)
      let mut 출력 = ""
      for 번호 in 0..<글자들.length {
        출력 = "{출력}{글자들[번호]}"
        if 번호 + 1 < 글자들.length && 끝들.indexOf(번호 + 1) != None { 출력 = "{출력}|" }
      }
      출력
    };
    case Err(오류) => "{오류}"
  }
}

let 글자단위 = typeset.BreakMode.Character
io.writeLine("ko {표시("가나", typeset.KinsokuRules.Unicode16Ko, 글자단위)}")
io.writeLine("zh {표시("甲,乙", typeset.KinsokuRules.Unicode16Zh, 글자단위)}")
io.writeLine("zh {표시("甲(乙)", typeset.KinsokuRules.Unicode16Zh, 글자단위)}")
for 글 in ["カァナ", "カーキ", "あゝい"] {
  io.writeLine("ja {표시(글, typeset.KinsokuRules.Unicode16Ja, 글자단위)}")
}
출력
ko 가|나
zh 甲,|乙
zh 甲|(乙)
ja カァ|ナ
ja カー|キ
ja あゝ|い
  • 가|나처럼 한글 음절 사이는 끊을 수 있습니다.
  • 쉼표 ,와 닫는 괄호 )는 줄 첫머리에 오지 않고 여는 괄호 (는 줄 끝에 오지 않습니다.
  • 작은 가나 ァ, 장음 부호 ー, 반복 부호 ゝ는 줄 첫머리에 오지 않습니다.

탐욕 줄바꿈과 최적 줄바꿈

탐욕 줄바꿈은 줄을 하나씩 채웁니다. 지금 줄에 들어가는 만큼 넣고 다음 줄로 넘어가며 뒤에 올 줄은 보지 않습니다. 최적 줄바꿈은 문단 전체에서 끊을 자리의 조합을 비교해 비용 합이 가장 작은 계획을 고릅니다.

TOPAZ
import std.io
import std.typeset

function 너비(글자: string) -> int {
  if 글자 == " " { return 2500 }
  match 글자.codePointAt(0) {
    case Some(코드값) => if 코드값 >= 4352 { 10000 } else { 5000 };
    case None => 0
  }
}

function 계획(글자들: Array<string>, 폭: int) -> Result<typeset.ParagraphResult, typeset.TypesetError> {
  let 묶음들 = 글자들.map(글자 => typeset.BreakCluster { text: 글자, advance: 너비(글자), trackingAfter: 0 })
  let 기회들 = typeset.classifyBreaks(묶음들, typeset.BreakPolicy {
    mode: typeset.BreakMode.Word, rules: typeset.KinsokuRules.Unicode16Ko,
    forbidBefore: [], forbidAfter: [], boundaryAllowed: 글자들.map(글자 => true),
    generatedHyphenEnds: [], protectFittingLatin: false, preserveShortWords: false,
    narrowestWordTarget: 폭, bodyWordTarget: 폭
  })?

  let mut 항목들: Array<typeset.Item> = []
  for 번호 in 0..<글자들.length {
    항목들.push(typeset.Item.BoxItem(typeset.Box { unitIndex: 번호, text: 글자들[번호], width: 너비(글자들[번호]), trackingAfter: 0 }))
  }
  for 기회 in 기회들 {
    let 규칙 = if 기회.kind == typeset.BreakKind.ForcedEnd { typeset.BreakRule.Forced } else { typeset.BreakRule.Allowed(0) }
    항목들.push(typeset.Item.PenaltyItem(typeset.Penalty { unitEnd: 기회.unitEnd, width: 0, rule: 규칙, kind: 기회.kind, flagged: false }))
  }

  typeset.breakParagraph(typeset.Paragraph {
    grid: typeset.DistanceGrid.MilliPoint,
    items: 항목들,
    geometry: typeset.LineGeometry { bodyTarget: 폭, firstTarget: 폭, dropCapSpan: 0, dropCapInset: 0 },
    justification: typeset.JustificationPolicy {
      mode: typeset.JustificationMode.Unjustified, minGapCount: 0, minLineWidthRatioM: 0,
      minFinalLineWidthRatioM: None, maxAdjustment: 0, maxGapAdjustment: 0, maxGapRatioR: 0,
      latinSpacing: None, cjkStretchRatio: 0.0
    },
    costModel: typeset.CostModel.FixedPoint,
    knuthPlass: typeset.KnuthPlassPolicy { toleranceR: 200000000, linePenalty: 10, fitnessDemerit: 10000, flaggedDemerit: 10000, finalFlaggedDemerit: 5000 }
  })
}

function 보이기(이름: string, 글자들: Array<string>, 계획: typeset.ParagraphPlan) {
  io.writeLine("{이름} {계획.totalCost}")
  for 줄 in 계획.lines {
    io.writeLine("  {글자들.slice(줄.start, 줄.trimmedEndExclusive).join("")} {줄.trimmedTextWidth}")
  }
}

let 글자들 = "줄을 하나씩 채우면 마지막 줄이 짧게 남습니다".scalars()
match 계획(글자들, 100000) {
  case Ok(결과) => {
    보이기("greedy", 글자들, 결과.greedy)
    보이기("optimized", 글자들, 결과.optimized)
  };
  case Err(오류) => io.writeLine("{오류}")
}
출력
greedy 685996370
  줄을 하나씩 채우면 85000
  마지막 줄이 짧게 75000
  남습니다 40000
optimized 115677384
  줄을 하나씩 52500
  채우면 마지막 줄이 85000
  짧게 남습니다 62500

탐욕 계획은 앞 두 줄을 가득 채우고 남습니다 한 어절을 마지막 줄에 남깁니다. 마지막 줄이 줄 폭의 52%보다 짧으면 비용이 크게 늘어나므로 최적 계획은 앞 줄을 줄여 세 줄을 고르게 맞춥니다. 숫자는 각 계획의 비용 합과 줄마다 끝 공백을 뺀 너비입니다.

breakParagraph는 두 계획을 모두 돌려줍니다. 쓸 계획은 ParagraphResult의 optimized와 greedy 가운데서 고르고 비용 계산 방식은 Paragraph.costModel로 고릅니다.

비용 모델입력이럴 때 씁니다
CostModel.FixedPoint상자만 있어도 됩니다. 격자는 MilliPoint만 받습니다.글자 사이에 글루를 두지 않는 한국어·중국어·일본어 문단, latinSpacing으로 낱말 간격을 평가하는 문단
CostModel.KnuthPlass자연 폭과 고른 페널티의 width를 더한 값이 목표 폭과 같은 줄은 글루가 없어도 됩니다. 그보다 짧은 줄에는 늘일 수 있는 글루가, 긴 줄에는 줄일 수 있는 글루가 있어야 합니다.낱말 사이 공백을 늘이고 줄여 양쪽 끝을 맞추는 문단

Knuth-Plass는 도널드 커누스와 마이클 플래스가 만든 줄바꿈 알고리즘입니다. 줄마다 글루를 얼마나 늘이거나 줄여야 하는지로 그 줄의 나쁨(badness)을 매기고 문단 전체 비용이 가장 작은 조합을 고릅니다. KnuthPlass 모델은 앞의 모든 끊을 자리를 후보로 비교합니다. FixedPoint 모델은 정수 밀리포인트로 비용을 계산합니다. 이 모델의 최적 계획은 소프트 하이픈으로 끝나는 줄을 세 줄 연달아 두지 않습니다. 두 모델 모두 비용이 같으면 먼저 넣은 후보를 고릅니다.

탐욕 계획을 세울 수 없으면 최적 계획이 있어도 breakParagraph는 NoGreedyPlan을 돌려줍니다. KnuthPlass에서 toleranceR가 낮을 때 이런 일이 생깁니다. 이때는 toleranceR를 높이거나 글루의 stretch를 넓히세요.

비용

줄 계획의 비용은 정수이고 작을수록 좋은 계획입니다. FixedPoint 모델의 줄 비용은 lineBadnessMpt와 같은 식으로 구한 값이고, 고른 페널티가 Allowed(n)이면 n을 그대로 더합니다. lineBadnessMpt로 줄 하나의 비용을 직접 계산해 볼 수 있습니다.

TOPAZ
import std.io
import std.typeset

let 목표 = 100000
let 공백 = typeset.BreakKind.LatinSpace
io.writeLine("fit    {typeset.lineBadnessMpt(100000, 목표, 10, 2, 공백, false, 0, 0)}")
io.writeLine("short  {typeset.lineBadnessMpt(90000, 목표, 9, 2, 공백, false, 0, 0)}")
io.writeLine("hyphen {typeset.lineBadnessMpt(100000, 목표, 10, 2, typeset.BreakKind.LatinHyphen, false, 0, 0)}")
io.writeLine("last   {typeset.lineBadnessMpt(40000, 목표, 4, 0, typeset.BreakKind.ForcedEnd, true, 0, 0)}")
io.writeLine("zero   {typeset.lineBadnessMpt(100000, 0, 10, 2, 공백, false, 0, 0)}")
출력
fit    Ok(10000000)
short  Ok(12770080)
hyphen Ok(75000000)
last   Ok(654000000)
zero   Err(TypesetError.InvalidInput(target must be positive; counts must be nonnegative))

아래 표는 FixedPoint 모델과 lineBadnessMpt의 비용 항목이고 B는 1,000,000입니다. 꼭 맞는 줄은 10B이고 하이픈으로 끝나면 65B가 더해져 75B입니다. 폭의 40%만 채운 마지막 줄에는 500B가 더해집니다. 목표 폭이 0이면 InvalidInput을 돌려줍니다.

조건더하는 비용
모든 줄10B
줄 너비와 목표 폭의 차이차이 비율의 제곱에 비례합니다. 낱말 사이 공백이 많을수록 작아집니다.
목표 폭을 넘친 줄20,000B 이상
하이픈이나 소프트 하이픈으로 끝난 줄65B
HardFallback 자리에서 끊은 줄50,000B
글자가 두 개 이하인 줄275B
마지막 줄차이 비용을 5분의 2로 줄입니다. 목표 폭의 52%보다 짧으면 500B를 더합니다.
justificationB 인수그대로 더합니다.

KnuthPlass 모델은 줄마다 조정 비율 r을 구합니다. 남는 폭은 목표 폭에서 줄의 자연 폭과 고른 페널티의 width를 뺀 값입니다. r은 남는 폭이 0 이상이면 늘임 합으로, 음수이면 줄임 합으로 나눈 값입니다. r이 −1보다 작거나 toleranceR / 100,000,000보다 큰 줄은 후보에서 뺍니다. 나눌 늘임 합이나 줄임 합이 0이면 남는 폭이 정확히 0인 줄만 받습니다. 나쁨은 100 × |r|³을 반올림한 값이며 10,000을 넘지 않습니다. 줄 비용은 (linePenalty + 나쁨)²에 다음 값을 더한 것입니다.

  • 페널티가 Allowed(n)이면 n이 0 이상일 때 n²을 더하고 음수일 때 n²을 뺍니다.
  • 이웃한 두 줄의 적합도 등급이 둘 이상 차이 나면 fitnessDemerit를 더합니다. 등급은 r이 −0.5 미만, 0.5 이하, 1 이하, 1 초과인 네 단계입니다.
  • flagged 페널티에서 두 줄 연달아 끊으면 flaggedDemerit를 더합니다.
  • 앞 줄이 flagged 페널티에서 끝났고 이 줄이 문단의 마지막 줄이면 finalFlaggedDemerit를 더합니다.

정수 계산이 넘치면 TypesetError를 돌려주지 않고 실행 오류 TPZ4004로 멈춥니다.

간격 맞추기

양쪽 정렬은 줄마다 남는 폭을 공백에 나눠 줄 끝을 맞춥니다. KnuthPlass 모델에서는 공백을 글루(Glue)로 넣습니다. 글루는 기본 폭 width와 늘일 수 있는 양 stretch, 줄일 수 있는 양 shrink를 가집니다. 다음 예제는 10pt 글꼴을 가정해 공백을 3,333, 늘임을 1,667, 줄임을 1,111 밀리포인트로 잡았습니다.

TOPAZ
import std.io
import std.typeset

let 공백 = 3333
let 늘임 = 1667
let 줄임 = 1111

function 계획(글자들: Array<string>, 폭: int) -> Result<typeset.ParagraphResult, typeset.TypesetError> {
  let mut 항목들: Array<typeset.Item> = []
  for 번호 in 0..<글자들.length {
    if 글자들[번호] == " " {
      항목들.push(typeset.Item.GlueItem(typeset.Glue { unitIndex: 번호, text: " ", width: 공백, trackingAfter: 0, stretch: 늘임, shrink: 줄임 }))
      항목들.push(typeset.Item.PenaltyItem(typeset.Penalty { unitEnd: 번호 + 1, width: 0, rule: typeset.BreakRule.Allowed(0), kind: typeset.BreakKind.LatinSpace, flagged: false }))
    } else {
      항목들.push(typeset.Item.BoxItem(typeset.Box { unitIndex: 번호, text: 글자들[번호], width: 5000, trackingAfter: 0 }))
    }
  }
  let 끝 = 글자들.length
  항목들.push(typeset.Item.GlueItem(typeset.Glue { unitIndex: 끝, text: "", width: 0, trackingAfter: 0, stretch: 100000000, shrink: 0 }))
  항목들.push(typeset.Item.PenaltyItem(typeset.Penalty { unitEnd: 끝 + 1, width: 0, rule: typeset.BreakRule.Forced, kind: typeset.BreakKind.ForcedEnd, flagged: false }))

  typeset.breakParagraph(typeset.Paragraph {
    grid: typeset.DistanceGrid.MilliPoint,
    items: 항목들,
    geometry: typeset.LineGeometry { bodyTarget: 폭, firstTarget: 폭, dropCapSpan: 0, dropCapInset: 0 },
    justification: typeset.JustificationPolicy {
      mode: typeset.JustificationMode.Unjustified, minGapCount: 0, minLineWidthRatioM: 0,
      minFinalLineWidthRatioM: None, maxAdjustment: 0, maxGapAdjustment: 0, maxGapRatioR: 0,
      latinSpacing: None, cjkStretchRatio: 0.0
    },
    costModel: typeset.CostModel.KnuthPlass,
    knuthPlass: typeset.KnuthPlassPolicy { toleranceR: 200000000, linePenalty: 10, fitnessDemerit: 10000, flaggedDemerit: 10000, finalFlaggedDemerit: 5000 }
  })
}

let 글자들 = "Typesetting splits a paragraph into lines and then spreads the spaces in each line evenly.".scalars()
match 계획(글자들, 150000) {
  case Ok(결과) => {
    for 줄 in 결과.optimized.lines {
      let 비율 = 줄.adjustmentRatioR
      let 간격 = if 비율 >= 0 { 공백 + 비율 * 늘임 / 100000000 } else { 공백 + 비율 * 줄임 / 100000000 }
      io.writeLine("{글자들.slice(줄.start, 줄.trimmedEndExclusive).join("")} | {비율} {간격}")
    }
  };
  case Err(오류) => io.writeLine("{오류}")
}
출력
Typesetting splits a paragraph | 100000000 5000
into lines and then spreads the | 40011998 4000
spaces in each line evenly. | 21667 3333

adjustmentRatioR는 조정 비율 r에 100,000,000을 곱한 정수입니다. r이 0 이상이면 공백 폭은 width + r × stretch이고 음수이면 width + r × shrink입니다. 첫 줄은 r이 1이라 공백을 3,333에서 5,000으로 늘립니다. 둘째 줄은 r이 약 0.40이라 4,000입니다.

마지막 줄은 끝에 늘임이 아주 큰 글루와 Forced 페널티를 두어 왼쪽에 붙입니다. Forced 페널티는 바로 앞 글루를 그 줄에 넣으므로 마지막 줄의 r이 0에 가깝습니다. 보통 끊는 자리에서는 줄 끝 글루를 버리고 다음 줄 첫머리의 글루도 건너뜁니다.

FixedPoint 모델은 글루 대신 JustificationPolicy로 정렬을 평가합니다. latinSpacing을 주면 낱말 사이 공백을 자연 폭의 minWordSpaceRatio배까지 줄이고 maxWordSpaceRatio배까지 늘릴 수 있다고 봅니다. 글자 사이는 maxLetterSpacingEm까지 벌릴 수 있다고 봅니다. 허용 범위는 차례로 0.81.0, 1.01.33, 0~0.015이고 벗어나면 InvalidPolicy를 돌려줍니다. 정렬 방식이 Unjustified가 아니고 latinSpacing이 있으면 최적 계획은 마지막 줄을 뺀 모든 줄을 정렬 정책이 허용하는 범위 안에서 채우는 계획을 먼저 찾습니다. 마지막 줄은 이 검사를 받지 않습니다. 그런 계획이 있으면 그 계획을 쓰고 optimized.boundedFillFeasible은 Some(true)입니다. 없으면 범위 조건 없이 다시 찾고 Some(false)입니다. 그 밖의 경우에는 None이고 탐욕 계획의 boundedFillFeasible은 늘 None입니다.

로케일

BreakPolicy.rules로 언어별 금칙을 고릅니다. 유니코드 규칙 셋은 UAX #14에서 바꿀 수 없는 결정을 그대로 두고 그 위에 언어별 금칙을 더합니다.

값따르는 규칙
Unicode16KoKLREQ(W3C 한국어 조판 요건) 7.1.1~7.1.4. Word 모드에서 폭에 맞는 한국어 어절을 지킵니다.
Unicode16ZhCLREQ(W3C 중국어 조판 요건) 6.1.1의 기본 금칙
Unicode16JaJLREQ 3.1.7~3.1.10의 엄격한 금칙, 숫자 앞뒤 기호, 숫자 안 자릿수 공백
AtlasDefault아틀라스가 써 온 규칙 집합. 유니코드 표 대신 고정된 문장부호 목록을 씁니다.

다음 예제는 같은 글을 Unicode16Ko, Unicode16Zh, Unicode16Ja 차례로 나눕니다.

TOPAZ
import std.io
import std.typeset

function 표시(글: string, 규칙집합: typeset.KinsokuRules, 방식: typeset.BreakMode) -> string {
  let 글자들 = 글.scalars()
  let 묶음들 = 글자들.map(글자 => typeset.BreakCluster { text: 글자, advance: 10000, trackingAfter: 0 })
  let 정책 = typeset.BreakPolicy {
    mode: 방식, rules: 규칙집합, forbidBefore: [], forbidAfter: [],
    boundaryAllowed: 글자들.map(글자 => true), generatedHyphenEnds: [],
    protectFittingLatin: false, preserveShortWords: false,
    narrowestWordTarget: 100000, bodyWordTarget: 100000
  }
  match typeset.classifyBreaks(묶음들, 정책) {
    case Ok(기회들) => {
      let 끝들 = 기회들.map(기회 => 기회.unitEnd)
      let mut 출력 = ""
      for 번호 in 0..<글자들.length {
        출력 = "{출력}{글자들[번호]}"
        if 번호 + 1 < 글자들.length && 끝들.indexOf(번호 + 1) != None { 출력 = "{출력}|" }
      }
      출력
    };
    case Err(오류) => "{오류}"
  }
}

let 규칙집합 = [typeset.KinsokuRules.Unicode16Ko, typeset.KinsokuRules.Unicode16Zh, typeset.KinsokuRules.Unicode16Ja]
for 글 in ["1 000", "#一", "1㎏"] {
  io.writeLine(규칙집합.map(규칙하나 => 표시(글, 규칙하나, typeset.BreakMode.Character)).join("   "))
}
출력
1 |000   1 |000   1 000
#|一   #|一   #一
1|㎏   1|㎏   1㎏

Unicode16Ja는 숫자 앞에 붙는 기호 #와 숫자 뒤에 붙는 단위 ㎏를 숫자와 떼지 않습니다. 숫자 사이 공백 1 000은 자릿수 구분으로 보고 끊지 않습니다. Unicode16Ko와 Unicode16Zh는 이 일본어 규칙을 쓰지 않습니다.

오류 처리

classifyBreaks, breakParagraph, lineBadnessMpt는 Result<_, TypesetError>를 돌려줍니다. 입력 값은 바꾸지 않고 결과 배열은 새로 만듭니다.

오류돌려주는 때
InvalidInput(message)글자 묶음이 비었거나 boundaryAllowed 길이가 묶음 수와 다를 때, 한 묶음 안에 반드시 끊는 자리가 있거나 boundaryAllowed가 그런 자리를 막을 때, unitIndex가 0부터 이어지지 않을 때, 글루의 늘임이나 줄임이 음수일 때, 페널티가 문단 밖을 가리킬 때, lineBadnessMpt의 목표 폭이 0 이하일 때
InvalidPolicy(message)LineGeometry의 목표 폭이 0 이하이거나 dropCapSpan이 0~3 밖일 때, FixedPoint에 MilliPoint가 아닌 격자를 줄 때, 정렬·간격·Knuth-Plass 값이 허용 범위 밖일 때, forbidBefore나 forbidAfter에 유니코드 스칼라가 아닌 값이 있을 때
NoBreakOpportunities고를 수 있는 줄바꿈 기회가 하나도 없을 때
NoGreedyPlan탐욕 계획을 문단 끝까지 세우지 못할 때
NoPlan최적 계획이 문단 끝까지 이어지는 줄 나눔을 찾지 못할 때. FixedPoint의 최적 계획은 너비가 postValidationWidthLimit를 넘는 줄을 후보에서 빼므로 끊을 자리가 모자라면 이 오류가 납니다.

공개 API

import std.typeset으로 가져온 뒤 typeset.breakParagraph, typeset.BreakKind.ForcedEnd처럼 씁니다. 예제의 변수와 함수 이름은 한국어로 지었고 모듈이 내보내는 이름과 필드 이름은 그대로 씁니다.

함수

함수서명설명
breakParagraph(input: Paragraph) -> Result<ParagraphResult, TypesetError>문단의 최적 계획과 탐욕 계획을 함께 세웁니다.
classifyBreaks(clusters: Array<BreakCluster>, policy: BreakPolicy) -> Result<Array<BreakOpportunity>, TypesetError>글자 묶음 경계마다 줄바꿈 기회와 종류를 판정합니다.
lineBadnessMpt(widthMpt: int, targetMpt: int, charCount: int, spaces: int, kind: BreakKind, isLast: bool, justificationB: int, hyphenAdvanceMpt: int) -> Result<int, TypesetError>FixedPoint 모델의 줄 비용을 계산합니다.
fromMilliPoints(value: int) -> Length<Pt>밀리포인트 정수를 길이 값으로 바꿉니다.
fromScaledPoints(value: int) -> Length<Pt>스케일드 포인트 정수를 그대로 길이 값으로 씁니다.

입력 타입

타입필드설명
BreakClustertext: string, advance: int, trackingAfter: int줄바꿈을 판정할 글자 묶음과 그 너비
BreakPolicymode: BreakMode, rules: KinsokuRules, forbidBefore: Array<int>, forbidAfter: Array<int>, boundaryAllowed: Array<bool>, generatedHyphenEnds: Array<int>, protectFittingLatin: bool, preserveShortWords: bool, narrowestWordTarget: int, bodyWordTarget: int줄바꿈 판정 정책. forbidBefore와 forbidAfter는 그 앞이나 뒤에서 끊지 않을 유니코드 스칼라 값입니다. boundaryAllowed는 묶음마다 하나씩 그 뒤에서 끊어도 되는지를 적습니다. generatedHyphenEnds는 소프트 하이픈을 넣을 수 있는 낱말 안 경계입니다. Word 모드는 너비가 bodyWordTarget 이하인 낱말 안의 HardFallback 자리를 버리고, rules가 Unicode16Ko이면 그런 너비의 한국어 어절 안 한글 사이 자리도 버립니다. 너비가 bodyWordTarget을 넘는 낱말에는 글자 사이에 HardFallback 자리를 더합니다.
BoxunitIndex: int, text: string, width: int, trackingAfter: int늘거나 줄지 않는 글자 단위
GlueunitIndex: int, text: string, width: int, trackingAfter: int, stretch: int, shrink: int늘이거나 줄일 수 있는 공백 단위
PenaltyunitEnd: int, width: int, rule: BreakRule, kind: BreakKind, flagged: bool처음 unitEnd개 단위 뒤 경계의 끊을 자리. 단위를 차지하지 않습니다. width는 그 자리에서 끊은 줄의 너비에 더하는 값입니다. KnuthPlass는 늘 더하고 FixedPoint는 kind가 LatinSoftHyphen일 때만 더합니다. 더한 값은 PlanLine.selectedPenaltyWidth에 남습니다. flagged는 하이픈 자리처럼 연달아 쓰지 않을 자리 표시이며 KnuthPlass만 비용에 반영합니다(비용 절의 flaggedDemerit와 finalFlaggedDemerit).
ItemBoxItem(Box), GlueItem(Glue), PenaltyItem(Penalty)문단을 이루는 항목
LineGeometrybodyTarget: int, firstTarget: int, dropCapSpan: int, dropCapInset: int줄 목표 폭. firstTarget은 첫 줄, bodyTarget은 나머지 줄에 씁니다. 첫 dropCapSpan줄(0~3)은 목표 폭에서 dropCapInset을 뺍니다.
LatinSpacingminWordSpaceRatio: float, maxWordSpaceRatio: float, maxLetterSpacingEm: float, fontSizePt: float라틴 낱말 간격과 자간의 허용 범위
JustificationPolicymode: JustificationMode, minGapCount: int, minLineWidthRatioM: int, minFinalLineWidthRatioM: Option<int>, maxAdjustment: int, maxGapAdjustment: int, maxGapRatioR: int, latinSpacing: Option<LatinSpacing>, cjkStretchRatio: floatFixedPoint 모델이 쓰는 정렬 정책. KnuthPlass 모델은 값을 검사만 합니다.
KnuthPlassPolicytoleranceR: int, linePenalty: int, fitnessDemerit: int, flaggedDemerit: int, finalFlaggedDemerit: intKnuthPlass 모델의 허용 비율(× 100,000,000)과 비용 항목
Paragraphgrid: DistanceGrid, items: Array<Item>, geometry: LineGeometry, justification: JustificationPolicy, costModel: CostModel, knuthPlass: KnuthPlassPolicybreakParagraph에 넘기는 문단

결과 타입

타입필드설명
BreakOpportunityunitEnd: int, kind: BreakKind처음 unitEnd개 묶음 뒤 경계의 줄바꿈 기회
PlanLinestart: int, endExclusive: int, trimmedEndExclusive: int, displayTextScalarLength: int, kind: BreakKind, target: int, rawTextWidth: int, trimmedTextWidth: int, costTextWidth: int, selectedPenaltyWidth: int, postValidationWidthLimit: Option<int>, cost: int, adjustmentRatioR: int줄 하나. costTextWidth는 비용 계산에 쓴 너비입니다. postValidationWidthLimit는 FixedPoint의 너비 검사 기준입니다. 마지막 줄이면 target + 1이고 다른 줄이면 target에 latinSpacing으로 계산한 줄임 용량(없으면 0)과 1을 더한 값입니다. 최적 계획은 끝 공백을 뺀 너비가 이 값을 넘는 줄을 버리고, 소프트 하이픈 자리에서는 페널티의 width도 그 너비에 넣습니다. 단위가 하나뿐인 줄은 버리지 않습니다. 탐욕 계획은 이 값을 기록만 하고 검사하지 않습니다. 그래서 단위가 하나뿐인 줄과 탐욕 계획의 줄은 이 값보다 넓을 수 있습니다. KnuthPlass에서는 None입니다. adjustmentRatioR는 FixedPoint에서 0입니다.
ParagraphPlanlines: Array<PlanLine>, totalCost: int, boundedFillFeasible: Option<bool>, grid: DistanceGrid줄 계획 하나와 비용 합. boundedFillFeasible은 간격 맞추기 절에서 설명한 범위 채우기 결과이며 최적 계획에만 기록되고 탐욕 계획에서는 None입니다.
ParagraphResultoptimized: ParagraphPlan, greedy: ParagraphPlan, breakOpportunityCount: int최적 계획, 탐욕 계획, 고를 수 있는 페널티 수

열거형

타입값설명
BreakKindKoreanCluster, LatinSpace, LatinHyphen, LatinSoftHyphen, ClosingPunctuation, HardFallback, ForcedEnd줄바꿈 기회의 종류
BreakModeWord, CharacterWord는 BreakPolicy 설명의 낱말 보호를 적용합니다. Character는 그 보호 없이 UAX #14와 금칙이 허용하는 자리를 모두 냅니다. Hello처럼 UAX #14가 끊지 않는 라틴 낱말 안은 Character에서도 끊지 않습니다.
BreakRuleAllowed(int), Forced, Forbidden페널티의 성격. Forced는 반드시 끊음, Forbidden은 끊지 않음입니다. Allowed(n)의 n은 그 자리에서 끊는 비용입니다. FixedPoint는 n을 그대로 더하고 KnuthPlass는 n이 0 이상이면 n²을 더하고 음수이면 n²을 뺍니다.
CostModelFixedPoint, KnuthPlass줄 비용 계산 방식
DistanceGridScaledPoint, MilliPoint너비 정수의 단위
JustificationModeUnjustified, LatinWordSpace, CjkInterCharacter정렬하지 않음, 낱말 사이 공백으로 정렬, 한중일 글자 사이로 정렬
KinsokuRulesAtlasDefault, Unicode16Ko, Unicode16Zh, Unicode16Ja줄바꿈 규칙 집합
TypesetErrorInvalidInput(string), InvalidPolicy(string), NoBreakOpportunities, NoGreedyPlan, NoPlan조판 함수의 오류