Стандартная библиотека

Вёрстка

Единицы длины, ширина знаков, места разрыва, кинсоку, жадное и оптимальное разбиение, стоимость и интервалы на примерах, а затем открытый API.

С std.typeset абзац разбивается на строки заданной ширины, а для каждой строки считается, насколько растянуть или сжать пробелы. Абзацы, где корейский, китайский или японский текст смешан с латиницей, тоже разбиваются по правилам Юникода и по правилам кинсоку каждого языка. Правила кинсоку не дают закрывающей скобке или точке начинать строку, а открывающей скобке заканчивать её.

Модуль подключается командой import std.typeset в языковом режиме 5.21. Чтобы запускать примеры, установите Топаз по странице Первая программа. Другие стандартные модули описаны в разделе Обзор стандартной библиотеки.

Разбить абзац на строки

Сохраните код в файл typeset-paragraph.tpz. Ширина задаётся в миллипунктах, это тысячная доля пункта. Для шрифта 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. Программа добавляет в абзац (Paragraph) бокс (Box) для каждого знака и штраф (Penalty) для каждого места разрыва. В конце абзаца стоит обязательный разрыв 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) показывает хранимое значение в масштабированных пунктах. В одном пункте 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 и правила с LB1 по LB31 из UAX #14 редакции 53, а затем добавляет кинсоку выбранной локали. Следующий пример ставит | в каждом месте разрыва.

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 (полуширинный).

Документ W3C JLREQ описывает требования к японской вёрстке. Он делит знаки препинания и кану на классы, например открывающие скобки, закрывающие скобки и малую кану. Для каждого класса указано, может ли знак начинать или заканчивать строку. 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 возвращает оба плана. Нужный план берите из полей optimized или greedy в ParagraphResult. Способ расчёта стоимости задаётся полем 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. Нулевая целевая ширина возвращает InvalidInput.

УсловиеДобавочная стоимость
Любая строка10B
Разница между шириной строки и целевой ширинойРастёт с квадратом доли разницы. Чем больше пробелов между словами, тем она меньше.
Строка шире цели20 000B и больше
Строка, которая кончается дефисом или мягким переносом65B
Разрыв в месте HardFallback50 000B
Строка из двух знаков или меньше275B
Последняя строкаСтоимость разницы уменьшается до двух пятых. Если строка короче 52% цели, добавляется 500B.
Аргумент justificationBДобавляется как есть

Модель KnuthPlass вычисляет для каждой строки коэффициент r. Остаток ширины равен цели минус естественная ширина строки и width выбранного штрафа. r равно остатку, делённому на суммарное растяжение, если остаток не меньше 0, и на суммарное сжатие, если он отрицателен. Строки с r меньше −1 или больше toleranceR / 100 000 000 отбрасываются. Если растяжение или сжатие, на которое делится остаток, равно 0, строка подходит только при нулевом остатке. Плохость равна округлённому 100 × |r|³ и не превышает 10 000. Стоимость строки равна (linePenalty + плохость)², к которой добавляется следующее.

  • Для штрафа Allowed(n) прибавляется n², если n не меньше 0, и вычитается n², если n отрицательно.
  • fitnessDemerit прибавляется, когда классы плотности двух соседних строк отличаются на два или больше. Всего классов четыре. Их границы по r равны −0,5, 0,5 и 1.
  • flaggedDemerit прибавляется, когда две строки подряд кончаются на штрафах с flagged.
  • finalFlaggedDemerit прибавляется, когда предыдущая строка кончилась на штрафе с flagged, а эта строка последняя в абзаце.

Переполнение целого числа не возвращает 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. Если r отрицателен, она равна 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,8 до 1,0, от 1,0 до 1,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
Unicode16JaСтрогие кинсоку JLREQ из разделов с 3.1.7 по 3.1.10, знаки до и после чисел, пробелы между группами цифр
AtlasDefaultНабор правил, который применял ATLAS. Вместо таблиц Юникода использует фиксированные списки знаков.

Следующий пример разбивает один и тот же текст по 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 убирает места HardFallback внутри слова, ширина которого не больше bodyWordTarget. С правилами 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: floatПравила выключки для модели FixedPoint. Модель KnuthPlass только проверяет эти значения.
KnuthPlassPolicytoleranceR: int, linePenalty: int, fitnessDemerit: int, flaggedDemerit: int, finalFlaggedDemerit: intДопуск (× 100 000 000) и слагаемые стоимости модели KnuthPlass
Paragraphgrid: DistanceGrid, items: Array<Item>, geometry: LineGeometry, justification: JustificationPolicy, costModel: CostModel, knuthPlass: KnuthPlassPolicyАбзац для breakParagraph

Типы результата

ТипПоляОписание
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. В FixedPoint значение adjustmentRatioR равно 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 никогда не разрывает. Число n в Allowed(n) задаёт стоимость разрыва в этом месте. FixedPoint прибавляет n как есть, а KnuthPlass прибавляет n², если n не меньше 0, и вычитает n², если n отрицательно.
CostModelFixedPoint, KnuthPlassСпособ расчёта стоимости строки
DistanceGridScaledPoint, MilliPointЕдиница целых ширин
JustificationModeUnjustified, LatinWordSpace, CjkInterCharacterБез выключки, выключка за счёт пробелов между словами, выключка между знаками CJK
KinsokuRulesAtlasDefault, Unicode16Ko, Unicode16Zh, Unicode16JaНабор правил разрыва
TypesetErrorInvalidInput(string), InvalidPolicy(string), NoBreakOpportunities, NoGreedyPlan, NoPlanОшибки функций вёрстки