length-metrics.ts 3.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123
  1. import type { LengthCountingMode, LengthNormalizeMode, LengthSpec } from "../models/length-governance.js";
  2. export type LengthLanguage = "zh" | "en";
  3. const REFERENCE_TARGET = 2200;
  4. const SOFT_RANGE_DELTA = 300;
  5. const HARD_RANGE_DELTA = 600;
  6. export function countChapterLength(
  7. content: string,
  8. countingMode: LengthCountingMode,
  9. ): number {
  10. const normalized = stripMarkdownMetadata(content);
  11. if (countingMode === "en_words") {
  12. const words = normalized.match(/[A-Za-z0-9]+(?:'[A-Za-z0-9]+)?/g);
  13. return words?.length ?? 0;
  14. }
  15. return normalized.replace(/\s+/g, "").length;
  16. }
  17. export function resolveLengthCountingMode(
  18. language: LengthLanguage = "zh",
  19. ): LengthCountingMode {
  20. return language === "en" ? "en_words" : "zh_chars";
  21. }
  22. export function formatLengthCount(
  23. count: number,
  24. countingMode: LengthCountingMode,
  25. ): string {
  26. return countingMode === "en_words" ? `${count} words` : `${count}字`;
  27. }
  28. export function buildLengthSpec(
  29. target: number,
  30. language: LengthLanguage = "zh",
  31. ): LengthSpec {
  32. const softDelta = scaleRangeDelta(target, SOFT_RANGE_DELTA);
  33. const hardDelta = Math.max(softDelta, scaleRangeDelta(target, HARD_RANGE_DELTA));
  34. const softMin = Math.max(1, target - softDelta);
  35. const softMax = target + softDelta;
  36. const hardMin = Math.max(1, target - hardDelta);
  37. const hardMax = target + hardDelta;
  38. return {
  39. target,
  40. softMin,
  41. softMax,
  42. hardMin,
  43. hardMax,
  44. countingMode: resolveLengthCountingMode(language),
  45. normalizeMode: "none",
  46. };
  47. }
  48. function scaleRangeDelta(target: number, referenceDelta: number): number {
  49. return Math.max(1, Math.floor((target * referenceDelta) / REFERENCE_TARGET));
  50. }
  51. export function isOutsideSoftRange(
  52. count: number,
  53. spec: Pick<LengthSpec, "softMin" | "softMax">,
  54. ): boolean {
  55. return count < spec.softMin || count > spec.softMax;
  56. }
  57. export function isOutsideHardRange(
  58. count: number,
  59. spec: Pick<LengthSpec, "hardMin" | "hardMax">,
  60. ): boolean {
  61. return count < spec.hardMin || count > spec.hardMax;
  62. }
  63. export function chooseNormalizeMode(
  64. count: number,
  65. spec: Pick<LengthSpec, "softMin" | "softMax">,
  66. ): LengthNormalizeMode {
  67. if (count < spec.softMin) return "expand";
  68. if (count > spec.softMax) return "compress";
  69. return "none";
  70. }
  71. function stripMarkdownMetadata(content: string): string {
  72. const lines = content.replace(/\r\n/g, "\n").replace(/^\uFEFF/, "").split("\n");
  73. const proseLines: string[] = [];
  74. let index = 0;
  75. if (lines[index]?.trim() === "---") {
  76. index += 1;
  77. while (index < lines.length && lines[index]?.trim() !== "---") {
  78. index += 1;
  79. }
  80. if (index < lines.length) {
  81. index += 1;
  82. }
  83. }
  84. let inFence = false;
  85. for (; index < lines.length; index += 1) {
  86. const line = lines[index] ?? "";
  87. const trimmed = line.trim();
  88. if (/^(```|~~~)/.test(trimmed)) {
  89. inFence = !inFence;
  90. continue;
  91. }
  92. if (inFence) {
  93. continue;
  94. }
  95. if (/^#{1,6}\s+/.test(trimmed)) {
  96. continue;
  97. }
  98. if (trimmed === "---" || trimmed === "...") {
  99. continue;
  100. }
  101. proseLines.push(line);
  102. }
  103. return proseLines.join("\n");
  104. }