#!/usr/bin/ruby -w # This script generates a HTML page to make it easier to edit rotated text. # Takes a single argument as input (e.g. "entry.rb"), writes HTML text to # stdout. # # To write a piece of text that can have multiple interpretations across # different angles, you might try the following steps: # # 1. Make some edits to the original text. # 2. Apply rotation, and check if the result has the expected alternative text. # 3. Repeat steps 1-2. # # This process is tedious because of the extra rotation step after each edit, # and also difficult because the cursor movements needed to indirectly edit the # alternative text is unintuitive. What you would like is a process where you # can edit the original and rotated texts simultaneously, which is what this # script provides: # # 1. Make a layout that's large enough to hold original and rotated texts. # # 2. Run this script to generate a HTML file from the layout, and open the # output HTML in a browser with javascript enabled. # # 3. Apply edits via the HTML page, then copy&paste the updated text when done. # # You still need to manually edit text with a fair bit of patience, but this # tool will let you iterate quickly on those manual edits. # # One more thing regarding Ruby specifically: writing text that is readable in # multiple orientations is not exactly trivial, but it's easier in Ruby # compared to many other languages, because Ruby has multiple quote-like # operators that are quite convenient. entry.rb makes use of %{}, %(), %w{}, # %W{}, //, and of course the common single+double quotes. # # The rotation algorithm is based on "A Fast Algorithm for General Raster # Rotation" by Alan Paeth, with two differences: # # - No support for blending pixels, since our pixels are characters and we want # to preserve the original character values. # # - Additional consideration regarding center of rotation. Original paper # doesn't say anything about shifting the input coordinates and simply apply # transforms to 0-based coordinate values as is, but due to details regarding # integer rounding, we need extra care with respect to rotation center to # make the output reversible, see get_center function. # # Even with extra care in selecting a more stable rotation center, there is no # guarantee that consecutive clockwise/counterclockwise rotation operations # will cancel each other out. It depends a lot on the shape of the input, and # often the rotation can be made reversible by inserting an extra line or two. # Rotation angle. Only angles between [-pi/2, pi/2] produce rotations. # Angles outside that range tend to distort the text instead, although # the transformation might still be reversible. ANGLE = Math::PI / 13 # Line height of edit elements. Width will be adjusted automatically to # ensure square character cells. LINE_HEIGHT = "0.9em" # Style class name to be attached to all editable text elements. EDIT_CLASS = "t" # Load input as a list of (y, x, serial, grapheme) tuples. # # We only need the coordinates and grapheme to rotate text. The serial # number is added to give each grapheme a unique ID, such that we can link # the same grapheme across different rotations to each other. def load_input data = [] cursor_x = cursor_y = 0 serial = 0 ARGF.each_line{|line| line.each_grapheme_cluster{|c| if c == " " cursor_x += 1 elsif c == "\t" cursor_x += 8 - cursor_x % 8 elsif c == "\n" || c == "\r\n" cursor_x = 0 cursor_y += 1 else data += [[cursor_y, cursor_x, serial, c]] cursor_x += 1 serial += 1 end } } return data end # Compute rotation center from center of mass. # # Using center of mass as rotation center increases the probability that # the rotation will be reversible, i.e. a clockwise rotation followed by # a counterclockwise rotation (or vice versa) results in the original text. # # Unfortunately, due to how we round away from zero, there will be # visible artifacts near the rotation center, e.g. straight edges that # come out more jagged than they should be after the rotation. We have # tried various things to reduce these artifacts: # # - Round toward positive infinity instead of zero. # - Set center of rotation to be center of bounding box. # - Set center of rotation to be corner of bounding box after rotation, # such that all coordinates before and after rotation remain positive. # # What happened with all of these is that we get artifacts at other # places instead of the center, and we also lose the reversibility # feature. Using center of mass appears to be the most useful option # despite the artifacts. # # Note that inserting and removing characters will cause the center of # mass to shift, which is why this script only generates an editor for # replacing text. To have an editor that also supports adds and # deletes, we will need to reimplement the rotation algorithm in the # generated javascript. I am not sure there is a great need for that # tool (I am not sure there is any need for this tool either). def get_center(data) cx = cy = 0 data.each{|t| cx += t[1] cy += t[0] } return cx / data.size, cy / data.size end # Apply rotation to coordinates. def rotate(data, cx, cy, a) rx = Math::tan(a / 2) ry = Math::sin(a) return data.map{|t| # Center contents. x = t[1] - cx y = t[0] - cy # Shear in X, shear in Y, then shear in X again. x -= (rx * y).round y += (ry * x).round x -= (rx * y).round # Return updated (y, x, serial, grapheme). [y, x, t[2], t[3]] } end # Generate block of HTML text with one span around each grapheme. def generate_block(data, id_prefix) min_y, min_x = data[0] data.each{|t| min_x = [min_x, t[1]].min min_y = [min_y, t[0]].min } cursor_x = min_x cursor_y = min_y text = "
\n"
data.sort.each{|t|
y, x = t
if cursor_y < y
text += "\n" * (y - cursor_y)
cursor_y = y
cursor_x = min_x
end
inner_text = t[3]
if inner_text == "&"
inner_text = "&"
elsif inner_text == "<"
inner_text = "<"
elsif inner_text == ">"
inner_text = ">"
end
text += " " * (x - cursor_x) +
'' +
inner_text +
""
cursor_x = x + 1
}
return text + "\n
"
end
# For each serial number, find the next serial number below.
# Returns a list of serial number pairs.
def generate_cursor_down_map(data)
# Sort serial numbers by rows and group them by columns.
columns = {}
data.sort.each{|t|
if columns[t[1]]
columns[t[1]] += [t[2]]
else
columns[t[1]] = [t[2]]
end
}
# Add serial numbers for each column.
serials = []
columns.each{|_, r|
serials += r.zip(r[1, r.size - 1] + [r[0]])
}
return serials
end
# For each serial number, find the next serial number to the right.
# Returns a list of serial number pairs.
def generate_cursor_right_map(data)
# Input tuples start with (row, column) coordinates, so if we sort by
# input tuples and rotate all serials by 1, that would get us the next
# serial numbers.
serials = data.sort.map{|t| t[2]}
return serials.zip(serials[1, serials.size - 1] + [serials[0]])
end
# Format serial number pairs as key value pairs.
def convert_cursor_map(prefix, serial_pairs)
# Generate map entries without trailing newline, since we get one extra
# newline from the template.
return serial_pairs.map{|t|
' "' + prefix + t[0].to_s + '": "' + prefix + t[1].to_s + '",'
} * "\n"
end
# Get range of coordinate values.
def get_size(data)
min_y, min_x = data[0]
max_x = min_x
max_y = min_y
data.each{|t|
y, x = t
min_x = [min_x, x].min
max_x = [max_x, x].max
min_y = [min_y, y].min
max_y = [max_y, y].max
}
return max_x - min_x + 1, max_y - min_y + 1
end
data = load_input
if data.empty?
return
end
cx, cy = get_center(data)
width, height = get_size(data)
left_data = rotate(data, cx, cy, -ANGLE)
right_data = rotate(data, cx, cy, ANGLE)
print <<"EOT"
#{generate_block(left_data, "L")} | #{generate_block(data, "M")} | #{generate_block(right_data, "R")} |
EOT