Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 91 additions & 0 deletions src/TestWrappedOutput.cc
Original file line number Diff line number Diff line change
Expand Up @@ -152,9 +152,100 @@ namespace archetype {
out() << "TestWrappedOutput::testWrapAcrossPuts_ finished." << endl;
}

void TestWrappedOutput::testClingingMarks_() {
UserOutput user_soutput = make_shared<StringOutput>();
StringOutput& strout(*dynamic_cast<StringOutput*>(user_soutput.get()));
UserOutput user_output = make_shared<WrappedOutput>(user_soutput);
WrappedOutput& wrout(*dynamic_cast<WrappedOutput*>(user_output.get()));

auto delta = [&](size_t& mark) {
string all = strout.getOutput();
string d = all.substr(mark);
mark = all.size();
return d;
};
size_t mark = 0;

// An em dash joins two words into one long token. The break falls on
// the far side of the dash, so the line ends with it and the next one
// opens with a word.
wrout.setMaxColumns(20);
user_output->put("I sat in a chair---sort of, anyway.");
user_output->endLine();
ARCHETYPE_TEST_EQUAL(delta(mark),
string("I sat in a chair---\n"
"sort of, anyway.\n"));

// The same for an ellipsis that runs into the next word.
wrout.resetCursor();
user_output->put("I waited and then...something moved.");
user_output->endLine();
ARCHETYPE_TEST_EQUAL(delta(mark),
string("I waited and then...\n"
"something moved.\n"));

// But never between the mark and punctuation: breaking "am I..."
// from "?" would strand the question mark at the head of a line.
wrout.resetCursor();
user_output->put("I cannot say who am I...?");
user_output->endLine();
ARCHETYPE_TEST_EQUAL(delta(mark),
string("I cannot say who am\n"
"I...?\n"));

// A single hyphen is not a dash: it belongs to the word it joins,
// and is no place to break a line.
wrout.resetCursor();
user_output->put("the lead-lined coveralls are heavy");
user_output->endLine();
ARCHETYPE_TEST_EQUAL(delta(mark),
string("the lead-lined\n"
"coveralls are heavy\n"));

// A fragment arriving with the line already full has nowhere to put
// its dash but the head of the next line. No break was chosen to do
// that; there was no room to choose anything. The margin is what is
// held to here, since overrunning it is the fault that shows.
wrout.resetCursor();
user_output->put("aaa bbb ccc ddd eeee");
user_output->put("---probably that meteor");
user_output->endLine();
ARCHETYPE_TEST_EQUAL(delta(mark),
string("aaa bbb ccc ddd eeee\n"
"---probably that\n"
"meteor\n"));

// The punctuation safety margin is for a stub and not for a clause.
// A dash-led fragment used to collect it and print past the margin.
wrout.resetCursor();
user_output->put("aaa bbb ccc ddd");
user_output->put("---and a long tail of words");
user_output->endLine();
for (const string& line : {string("aaa bbb ccc ddd---"),
string("and a long tail of"),
string("words")}) {
ARCHETYPE_TEST(line.size() <= 20);
}
ARCHETYPE_TEST_EQUAL(delta(mark),
string("aaa bbb ccc ddd---\n"
"and a long tail of\n"
"words\n"));

// A stub still gets its concession: a lone period arriving on the
// margin stays with the sentence it ends.
wrout.resetCursor();
user_output->put("aaa bbb ccc ddd eeee");
user_output->put(".");
user_output->endLine();
ARCHETYPE_TEST_EQUAL(delta(mark), string("aaa bbb ccc ddd eeee.\n"));

out() << "TestWrappedOutput::testClingingMarks_ finished." << endl;
}

void TestWrappedOutput::runTests_() {
testBasicWrap_();
testCenter_();
testWrapAcrossPuts_();
testClingingMarks_();
}
}
1 change: 1 addition & 0 deletions src/TestWrappedOutput.hh
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ namespace archetype {
void testBasicWrap_();
void testCenter_();
void testWrapAcrossPuts_();
void testClingingMarks_();
protected:
virtual void runTests_() override;
public:
Expand Down
67 changes: 65 additions & 2 deletions src/WrappedOutput.cc
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,37 @@ using namespace std;
namespace archetype {
const int SafetyMargin = 3;

namespace {
// A mark that clings to the word in front of it: the "--" and "---"
// that stand in for an em dash, and the "..." of an ellipsis. A lone
// hyphen belongs to the word it joins and a lone period ends a
// sentence, so neither of those needs a rule here.
bool isClingingMark(char c) {
return c == '-' or c == '.';
}

// Does the text at p open with such a mark? No break is chosen that
// would put one at the head of a line. A break forced for want of
// anywhere else to put it is another matter: the margin is held to
// in that case, the same as for a word too long to fit.
bool opensWithMark(std::string::const_iterator p,
std::string::const_iterator end) {
return end - p >= 2 and isClingingMark(p[0]) and p[1] == p[0];
}

// Would a line ending at p end with such a mark, and the next one
// open with a word? This is the one position inside a word where a
// break may fall: "the chair---" | "sort of". The word on the far
// side is the point of the test -- breaking "am I..." from "?" would
// strand the question mark, which is the very thing being avoided.
bool followsMark(std::string::const_iterator begin,
std::string::const_iterator p) {
return p - begin >= 2 and
isClingingMark(p[-1]) and p[-2] == p[-1] and
isalnum(static_cast<unsigned char>(*p));
}
}

WrappedOutput::WrappedOutput(UserOutput output, int max_columns):
output_{output} {
setMaxColumns(max_columns);
Expand Down Expand Up @@ -47,14 +78,46 @@ namespace archetype {

int remaining = max(0, maxColumns_ - cursor_);
// Keep trailing punctuation from being orphaned on the next line.
// The concession is meant for a stub -- a period, a comma, a closing
// quote -- and not for a whole clause that merely opens with a mark,
// which is what a fragment beginning with a dash looks like from
// here. Handing those three columns to fifty characters of prose
// just prints a line past the margin.
if (not s.empty() and ispunct(static_cast<unsigned char>(s[0]))) {
remaining += SafetyMargin;
auto first_space = ranges::find_if(s, [](unsigned char c) {
return isspace(c);
});
if (first_space - s.begin() <= SafetyMargin) {
remaining += SafetyMargin;
}
}

while (int(s.size()) > remaining) {
// Whether a break may fall at p. Whitespace is the usual place;
// the far side of a dash or an ellipsis is the other. Neither
// will do if what follows opens with such a mark, because a line
// may not begin with one: the mark belongs to the word behind it
// and has to travel with it.
auto marksNextLine = [&s](string::const_iterator p) {
while (p != s.cend() and isspace(static_cast<unsigned char>(*p))) {
++p;
}
return opensWithMark(p, s.cend());
};
auto breakable = [&](string::const_iterator p) {
if (p == s.cbegin()) {
return false;
}
if (not isspace(static_cast<unsigned char>(*p)) and
not followsMark(s.cbegin(), p)) {
return false;
}
return not marksNextLine(p);
};

// Walk backward to find a breaking point.
auto cut_p = s.begin() + remaining;
while (not isspace(static_cast<unsigned char>(*cut_p)) and cut_p != s.begin()) {
while (cut_p != s.begin() and not breakable(cut_p)) {
--cut_p;
}

Expand Down
Loading