| #!/usr/bin/env python3 |
| # SPDX-License-Identifier: GPL-2.0 |
| # |
| # Leverage Python's unicodedata module to generate ucs_width_table.h |
| |
| import unicodedata |
| import sys |
| import argparse |
| |
| # This script's file name |
| from pathlib import Path |
| this_file = Path(__file__).name |
| |
| # Default output file name |
| DEFAULT_OUT_FILE = "ucs_width_table.h" |
| |
| # --- Global Constants for Width Assignments --- |
| |
| # Known zero-width characters |
| KNOWN_ZERO_WIDTH = ( |
| 0x200B, # ZERO WIDTH SPACE |
| 0x200C, # ZERO WIDTH NON-JOINER |
| 0x200D, # ZERO WIDTH JOINER |
| 0x2060, # WORD JOINER |
| 0xFEFF # ZERO WIDTH NO-BREAK SPACE (BOM) |
| ) |
| |
| # Zero-width emoji modifiers and components |
| # NOTE: Some of these characters would normally be single-width according to |
| # East Asian Width properties, but we deliberately override them to be |
| # zero-width because they function as modifiers in emoji sequences. |
| EMOJI_ZERO_WIDTH = [ |
| # Skin tone modifiers |
| (0x1F3FB, 0x1F3FF), # Emoji modifiers (skin tones) |
| |
| # Variation selectors (note: VS16 is treated specially in vt.c) |
| (0xFE00, 0xFE0F), # Variation Selectors 1-16 |
| |
| # Gender and hair style modifiers |
| # These would be single-width by Unicode properties, but are zero-width |
| # when part of emoji |
| (0x2640, 0x2640), # Female sign |
| (0x2642, 0x2642), # Male sign |
| (0x26A7, 0x26A7), # Transgender symbol |
| (0x1F9B0, 0x1F9B3), # Hair components (red, curly, white, bald) |
| |
| # Tag characters |
| (0xE0020, 0xE007E), # Tags |
| ] |
| |
| # Regional indicators (flag components) |
| REGIONAL_INDICATORS = (0x1F1E6, 0x1F1FF) # Regional indicator symbols A-Z |
| |
| # Double-width emoji ranges |
| # |
| # Many emoji characters are classified as single-width according to Unicode |
| # Standard Annex #11 East Asian Width property (N or Neutral), but we |
| # deliberately override them to be double-width. References: |
| # 1. Unicode Technical Standard #51: Unicode Emoji |
| # (https://www.unicode.org/reports/tr51/) |
| # 2. Principle of "emoji presentation" in WHATWG CSS Text specification |
| # (https://drafts.csswg.org/css-text-3/#character-properties) |
| # 3. Terminal emulator implementations (iTerm2, Windows Terminal, etc.) which |
| # universally render emoji as double-width characters regardless of their |
| # Unicode EAW property |
| # 4. W3C Work Item: Requirements for Japanese Text Layout - Section 3.8.1 |
| # Emoji width (https://www.w3.org/TR/jlreq/) |
| EMOJI_RANGES = [ |
| (0x1F000, 0x1F02F), # Mahjong Tiles (EAW: N, but displayed as double-width) |
| (0x1F0A0, 0x1F0FF), # Playing Cards (EAW: N, but displayed as double-width) |
| (0x1F300, 0x1F5FF), # Miscellaneous Symbols and Pictographs |
| (0x1F600, 0x1F64F), # Emoticons |
| (0x1F680, 0x1F6FF), # Transport and Map Symbols |
| (0x1F700, 0x1F77F), # Alchemical Symbols |
| (0x1F780, 0x1F7FF), # Geometric Shapes Extended |
| (0x1F800, 0x1F8FF), # Supplemental Arrows-C |
| (0x1F900, 0x1F9FF), # Supplemental Symbols and Pictographs |
| (0x1FA00, 0x1FA6F), # Chess Symbols |
| (0x1FA70, 0x1FAFF), # Symbols and Pictographs Extended-A |
| ] |
| |
| def create_width_tables(): |
| """ |
| Creates Unicode character width tables and returns the data structures. |
| |
| Returns: |
| tuple: (zero_width_ranges, double_width_ranges) |
| """ |
| |
| # Width data mapping |
| width_map = {} # Maps code points to width (0, 1, 2) |
| |
| # Mark emoji modifiers as zero-width |
| for start, end in EMOJI_ZERO_WIDTH: |
| for cp in range(start, end + 1): |
| width_map[cp] = 0 |
| |
| # Mark all regional indicators as single-width as they are usually paired |
| # providing a combined width of 2 when displayed together. |
| start, end = REGIONAL_INDICATORS |
| for cp in range(start, end + 1): |
| width_map[cp] = 1 |
| |
| # Process all assigned Unicode code points (Basic Multilingual Plane + |
| # Supplementary Planes) Range 0x0 to 0x10FFFF (the full Unicode range) |
| for block_start in range(0, 0x110000, 0x1000): |
| block_end = block_start + 0x1000 |
| for cp in range(block_start, block_end): |
| try: |
| char = chr(cp) |
| |
| # Skip if already processed |
| if cp in width_map: |
| continue |
| |
| # Check for combining marks and a format characters |
| category = unicodedata.category(char) |
| |
| # Combining marks |
| if category.startswith('M'): |
| width_map[cp] = 0 |
| continue |
| |
| # Format characters |
| # Since we have no support for bidirectional text, all format |
| # characters (category Cf) can be treated with width 0 (zero) |
| # for simplicity, as they don't need to occupy visual space |
| # in a non-bidirectional text environment. |
| if category == 'Cf': |
| width_map[cp] = 0 |
| continue |
| |
| # Known zero-width characters |
| if cp in KNOWN_ZERO_WIDTH: |
| width_map[cp] = 0 |
| continue |
| |
| # Use East Asian Width property |
| eaw = unicodedata.east_asian_width(char) |
| if eaw in ('F', 'W'): # Fullwidth or Wide |
| width_map[cp] = 2 |
| elif eaw in ('Na', 'H', 'N', 'A'): # Narrow, Halfwidth, Neutral, Ambiguous |
| width_map[cp] = 1 |
| else: |
| # Default to single-width for unknown |
| width_map[cp] = 1 |
| |
| except (ValueError, OverflowError): |
| # Skip invalid code points |
| continue |
| |
| # Process Emoji - generally double-width |
| for start, end in EMOJI_RANGES: |
| for cp in range(start, end + 1): |
| if cp not in width_map or width_map[cp] != 0: # Don't override zero-width |
| try: |
| char = chr(cp) |
| width_map[cp] = 2 |
| except (ValueError, OverflowError): |
| continue |
| |
| # Optimize to create range tables |
| def ranges_optimize(width_data, target_width): |
| points = sorted([cp for cp, width in width_data.items() if width == target_width]) |
| if not points: |
| return [] |
| |
| # Group consecutive code points into ranges |
| ranges = [] |
| start = points[0] |
| prev = start |
| |
| for cp in points[1:]: |
| if cp > prev + 1: |
| ranges.append((start, prev)) |
| start = cp |
| prev = cp |
| |
| # Add the last range |
| ranges.append((start, prev)) |
| return ranges |
| |
| # Extract ranges for each width |
| zero_width_ranges = ranges_optimize(width_map, 0) |
| double_width_ranges = ranges_optimize(width_map, 2) |
| |
| return zero_width_ranges, double_width_ranges |
| |
| def write_tables(zero_width_ranges, double_width_ranges, out_file=DEFAULT_OUT_FILE): |
| """ |
| Write the generated tables to C header file. |
| |
| The output uses a single sorted-by-`first` table per region (BMP and |
| non-BMP), with zero-width and double-width ranges merged together. The |
| non-BMP table also hosts the BMP double-width bitmap in spare bits of |
| `last`. See the encoding comment at the top of ucs.c for the layout. |
| |
| Args: |
| zero_width_ranges: List of (start, end) ranges for zero-width characters |
| double_width_ranges: List of (start, end) ranges for double-width characters |
| out_file: Output file name (default: DEFAULT_OUT_FILE) |
| """ |
| |
| # Bits per BMP-bitmap chunk hosted in one non-BMP entry's `last` field. |
| # 8 bits makes `idx / BITS_PER_CHUNK` / `idx % BITS_PER_CHUNK` compile to |
| # a cheap shift+mask in the lookup. The chunk size is also emitted as |
| # UCS_NONBMP_BMP_BITS in the generated header so ucs.c stays in sync. |
| BITS_PER_CHUNK = 8 |
| |
| # Function to split ranges into BMP (16-bit) and non-BMP (above 16-bit) |
| def split_ranges_by_size(ranges): |
| bmp_ranges = [] |
| non_bmp_ranges = [] |
| |
| for start, end in ranges: |
| if end <= 0xFFFF: |
| bmp_ranges.append((start, end)) |
| elif start > 0xFFFF: |
| non_bmp_ranges.append((start, end)) |
| else: |
| # Split the range at 0xFFFF |
| bmp_ranges.append((start, 0xFFFF)) |
| non_bmp_ranges.append((0x10000, end)) |
| |
| return bmp_ranges, non_bmp_ranges |
| |
| # Split ranges into BMP and non-BMP |
| zero_width_bmp, zero_width_non_bmp = split_ranges_by_size(zero_width_ranges) |
| double_width_bmp, double_width_non_bmp = split_ranges_by_size(double_width_ranges) |
| |
| # Merge zero- and double-width ranges per region, tagging each with its |
| # width, then sort by `first` so binary search works on the union. |
| bmp_entries = sorted( |
| [(s, e, 0) for s, e in zero_width_bmp] + |
| [(s, e, 2) for s, e in double_width_bmp], |
| key=lambda t: t[0]) |
| nonbmp_entries = sorted( |
| [(s, e, 0) for s, e in zero_width_non_bmp] + |
| [(s, e, 2) for s, e in double_width_non_bmp], |
| key=lambda t: t[0]) |
| |
| # Build the BMP double-width bitmap: one bit per BMP entry (in sort |
| # order), set iff that entry is double-width. Pack into BITS_PER_CHUNK- |
| # wide chunks, with bit j of the chunk corresponding to entry |
| # (chunk_index * BITS_PER_CHUNK + j). |
| bmp_w2_bits = [1 if w == 2 else 0 for _, _, w in bmp_entries] |
| n_chunks = (len(bmp_w2_bits) + BITS_PER_CHUNK - 1) // BITS_PER_CHUNK |
| |
| if n_chunks > len(nonbmp_entries): |
| raise RuntimeError( |
| f"BMP bitmap needs {n_chunks} host entries, " |
| f"but only {len(nonbmp_entries)} non-BMP entries are available") |
| |
| chunks = [] # list of (base_index, end_index, packed_value) |
| for c in range(n_chunks): |
| base = c * BITS_PER_CHUNK |
| end_idx = min(base + BITS_PER_CHUNK - 1, len(bmp_w2_bits) - 1) |
| value = 0 |
| for j in range(BITS_PER_CHUNK): |
| k = base + j |
| if k < len(bmp_w2_bits) and bmp_w2_bits[k]: |
| value |= 1 << j |
| chunks.append((base, end_idx, value)) |
| |
| # Function to generate code point description comments |
| def get_code_point_comment(start, end): |
| try: |
| start_char_desc = unicodedata.name(chr(start)) |
| if start == end: |
| return f"/* {start_char_desc} */" |
| else: |
| end_char_desc = unicodedata.name(chr(end)) |
| return f"/* {start_char_desc} - {end_char_desc} */" |
| except: |
| if start == end: |
| return f"/* U+{start:04X} */" |
| else: |
| return f"/* U+{start:04X} - U+{end:04X} */" |
| |
| # Generate C tables |
| with open(out_file, 'w') as f: |
| f.write(f"""\ |
| /* SPDX-License-Identifier: GPL-2.0 */ |
| /* |
| * {out_file} - Unicode character width |
| * |
| * Auto-generated by {this_file} |
| * |
| * Unicode Version: {unicodedata.unidata_version} |
| * |
| * Zero-width and double-width ranges are merged into one sorted-by-`first` |
| * table per region. The non-BMP table additionally hosts the BMP |
| * double-width bitmap in the low {BITS_PER_CHUNK} bits of `last` of its |
| * first {n_chunks} entries (covering {len(bmp_w2_bits)} BMP entries). |
| * See ucs.c for the encoding details and the lookup code. |
| */ |
| |
| /* Bits per BMP-bitmap chunk hosted in one non-BMP entry's `last` field. */ |
| #define UCS_NONBMP_BMP_BITS {BITS_PER_CHUNK} |
| |
| /* Combined zero- and double-width ranges |
| * (BMP - Basic Multilingual Plane, U+0000 to U+FFFF). */ |
| static const struct ucs_width16 ucs_bmp_ranges[] = {{ |
| """) |
| |
| for s, e, w in bmp_entries: |
| macro = "BMP_0WIDTH" if w == 0 else "BMP_2WIDTH" |
| comment = get_code_point_comment(s, e) |
| f.write(f"\t{{ {macro}(0x{s:04X}, 0x{e:04X}) }}, {comment}\n") |
| |
| f.write(f"""\ |
| }}; |
| |
| /* Combined zero- and double-width ranges (non-BMP, U+10000 and above). |
| * The first {n_chunks} entries host the BMP double-width bitmap in the low |
| * {BITS_PER_CHUNK} bits of `last`. */ |
| static const struct ucs_width32 ucs_nonbmp_ranges[] = {{ |
| """) |
| |
| for i, (s, e, w) in enumerate(nonbmp_entries): |
| macro = "RANGE_0WIDTH" if w == 0 else "RANGE_2WIDTH" |
| comment = get_code_point_comment(s, e) |
| if i < len(chunks): |
| base, end_idx, value = chunks[i] |
| f.write( |
| f"\t{{ {macro}(0x{s:05X}, 0x{e:05X}) {comment}\n" |
| f"\t | BMP_2W_BITS(0b{value:0{BITS_PER_CHUNK}b}) }}," |
| f" /* BMP entries [{base:>3}..{end_idx:>3}] */\n") |
| else: |
| f.write(f"\t{{ {macro}(0x{s:05X}, 0x{e:05X}) }}, {comment}\n") |
| |
| f.write("};\n") |
| |
| if __name__ == "__main__": |
| # Parse command line arguments |
| parser = argparse.ArgumentParser(description="Generate Unicode width tables") |
| parser.add_argument("-o", "--output", dest="output_file", default=DEFAULT_OUT_FILE, |
| help=f"Output file name (default: {DEFAULT_OUT_FILE})") |
| args = parser.parse_args() |
| |
| # Write tables to header file |
| zero_width_ranges, double_width_ranges = create_width_tables() |
| write_tables(zero_width_ranges, double_width_ranges, out_file=args.output_file) |
| |
| # Print summary |
| zero_width_count = sum(end - start + 1 for start, end in zero_width_ranges) |
| double_width_count = sum(end - start + 1 for start, end in double_width_ranges) |
| n_zero = len(zero_width_ranges) |
| n_double = len(double_width_ranges) |
| print(f"Generated {args.output_file} with:") |
| print(f"- {n_zero} zero-width ranges covering ~{zero_width_count} code points") |
| print(f"- {n_double} double-width ranges covering ~{double_width_count} code points") |
| print(f"- {n_zero + n_double} merged ranges total") |
| print(f"- Unicode Version: {unicodedata.unidata_version}") |