From 003ebacba57c0da582689227f42413bedf66cb6d Mon Sep 17 00:00:00 2001 From: Rossi-Luciano Date: Fri, 3 Jul 2026 21:33:30 -0300 Subject: [PATCH 1/3] feat(pubmed): adicionar Suffix e CollectiveName em AuthorList MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implementa add_suffix e add_collective_name em packtools/sps/formats/pubmed.py, usando dados já expostos pelo model article_contribs.Contrib (contrib_name["suffix"] e collab). A DTD do PubMed exige que tenha nome pessoal (FirstName/MiddleName/LastName/Suffix) OU CollectiveName, nunca os dois: (((FirstName, MiddleName?, LastName, Suffix?, Initials?) | CollectiveName), affiliation, Identifier*). xml_pubmed_author_list agora respeita essa exclusividade: contribs com (sem ) geram só CollectiveName, os demais geram FirstName/LastName/Suffix como antes. Corrige também um bug em get_affiliations: contribs sem afiliação vinculada (comum no caso de CollectiveName, cujo único xref costuma ser para author-notes) não tinham a chave "affs" no dict, causando TypeError ao iterar author_reg.get("affs"). Agora usa `or []` como fallback. Refs #1226, #1236 --- packtools/sps/formats/pubmed.py | 60 ++++++++++++----------- tests/sps/formats/test_pubmed.py | 82 ++++++++++++++++++++++++++++++++ 2 files changed, 115 insertions(+), 27 deletions(-) diff --git a/packtools/sps/formats/pubmed.py b/packtools/sps/formats/pubmed.py index f99b2651d..634fd94f1 100644 --- a/packtools/sps/formats/pubmed.py +++ b/packtools/sps/formats/pubmed.py @@ -305,10 +305,26 @@ def add_last_name(author_reg, author_tag): author_tag.append(last) +def add_suffix(author_reg, author_tag): + author_suffix = author_reg.get("contrib_name", {}).get("suffix") + if author_suffix: + suffix = ET.Element("Suffix") + suffix.text = author_suffix + author_tag.append(suffix) + + +def add_collective_name(author_reg, author_tag): + collab = author_reg.get("collab") + if collab: + collective_name = ET.Element("CollectiveName") + collective_name.text = collab + author_tag.append(collective_name) + + def get_affiliations(author_reg, xml_tree): affiliations = aff.AffiliationExtractor(xml_tree).get_affiliation_dict(subtag=False) affiliation_list = [] - for item in author_reg.get("affs"): + for item in author_reg.get("affs") or []: affiliation_list.append( affiliations.get(item.get("id"), {}).get("institution", {})[0].get("original") ) @@ -342,33 +358,23 @@ def xml_pubmed_author_list(xml_pubmed, xml_tree): author_list_tag = ET.Element("AuthorList") for author_reg in authors: author_tag = ET.Element("Author") - add_first_name(author_reg, author_tag) - - # TODO - # add_middle_name(author_reg, author_tag) - # The Author’s full middle name, or initial if the full name is not available. - # Multiple names are allowed in this tag. - # There is no example of using this value in the files. - add_last_name(author_reg, author_tag) - - # TODO - # add_suffix(author_reg, author_tag) - # The Author's suffix, if any, e.g. "Jr", "Sr", "II", "IV". Do not include honorific titles, - # e.g. "M.D.", "Ph.D.". - # There is no example of using this value in the files - - # TODO - # add_collective_name(author_reg, author_tag) - # The name of the authoring committee or organization. The CollectiveName tag should be placed within - # an Author tag. Omit extraneous text like, “on behalf of.” - # Please see the following example: - # - # - # Plastic Surgery Educational Foundation DATA Committee - # - # - # There is no example of using this value in the files + # A DTD do PubMed exige que Author tenha nome pessoal + # (FirstName/MiddleName/LastName/Suffix) OU CollectiveName, + # nunca os dois ao mesmo tempo. + if author_reg.get("collab"): + add_collective_name(author_reg, author_tag) + else: + add_first_name(author_reg, author_tag) + + # TODO + # add_middle_name(author_reg, author_tag) + # The Author’s full middle name, or initial if the full name is not available. + # Multiple names are allowed in this tag. + # There is no example of using this value in the files. + + add_last_name(author_reg, author_tag) + add_suffix(author_reg, author_tag) affiliations = get_affiliations(author_reg, xml_tree) add_affiliations(affiliations, author_tag) diff --git a/tests/sps/formats/test_pubmed.py b/tests/sps/formats/test_pubmed.py index a8afad598..1fdfb2dda 100644 --- a/tests/sps/formats/test_pubmed.py +++ b/tests/sps/formats/test_pubmed.py @@ -1012,6 +1012,88 @@ def test_xml_pubmed_author_list_without_author(self): self.assertEqual(obtained, expected) + def test_xml_pubmed_author_list_with_suffix(self): + expected = ( + '
' + '' + '' + 'Rogerio' + 'Meneghini' + 'Junior' + 'Some University' + '' + '' + '
' + ) + xml_pubmed = ET.fromstring( + '
' + ) + xml_tree = ET.fromstring( + '
' + '' + '' + '' + '' + '' + 'Meneghini' + 'Rogerio' + 'Junior' + '' + '1' + '' + '' + '' + 'Some University' + '' + '' + '' + '
' + ) + + xml_pubmed_author_list(xml_pubmed, xml_tree) + + obtained = ET.tostring(xml_pubmed, encoding="utf-8").decode("utf-8") + + self.assertEqual(obtained, expected) + + def test_xml_pubmed_author_list_with_collective_name(self): + expected = ( + '
' + '' + '' + 'The SciELO Group' + '' + '' + '
' + ) + xml_pubmed = ET.fromstring( + '
' + ) + xml_tree = ET.fromstring( + '
' + '' + '' + '' + '' + 'The SciELO Group' + '1' + '' + '' + '' + '' + '
' + ) + + xml_pubmed_author_list(xml_pubmed, xml_tree) + + obtained = ET.tostring(xml_pubmed, encoding="utf-8").decode("utf-8") + + self.assertEqual(obtained, expected) + def test_xml_pubmed_publication_type(self): # TODO # Originalmente, espera-se que o valor da tag seja Journal Article From a53c3018b235b7b27d02f04164ca5d7e07d30f36 Mon Sep 17 00:00:00 2001 From: Rossi-Luciano Date: Fri, 3 Jul 2026 21:57:51 -0300 Subject: [PATCH 2/3] feat(pubmed): implementar GroupList/Group/GroupName/IndividualName MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Para autoria em grupo no padrão SPS 1.10: The SciELO Group ... ... xml_pubmed_author_list agora exclui de AuthorList os contribs de contrib-group[@content-type="collab-list"] (antes ficavam achatados como Author comuns, duplicando a autoria do grupo). Uma nova função xml_pubmed_group_list monta GroupList/Group/GroupName/IndividualName a partir desses mesmos contribs, reaproveitando add_first_name, add_last_name, add_suffix, add_affiliations e add_orcid (mesma forma de dict usada em AuthorList, já expõe contrib-group-type via article_contribs.TextContribs.main_contribs). GroupName vem do do contrib-group irmão (sem content-type). Segue a ordem exigida pela DTD do PubMed dentro de Article: AuthorList?, GroupList?, PublicationType*. Refs #1226, #1237 --- packtools/sps/formats/pubmed.py | 97 ++++++++++++++++----- tests/sps/formats/test_pubmed.py | 144 +++++++++++++++++++++++++++++++ 2 files changed, 220 insertions(+), 21 deletions(-) diff --git a/packtools/sps/formats/pubmed.py b/packtools/sps/formats/pubmed.py index 634fd94f1..c4504e9d4 100644 --- a/packtools/sps/formats/pubmed.py +++ b/packtools/sps/formats/pubmed.py @@ -270,6 +270,7 @@ def pipeline_pubmed(xml_tree, pretty_print=True): xml_pubmed_elocation_pipe(xml_pubmed, xml_tree) xml_pubmed_language_pipe(xml_pubmed, xml_tree) xml_pubmed_author_list(xml_pubmed, xml_tree) + xml_pubmed_group_list(xml_pubmed, xml_tree) xml_pubmed_publication_type(xml_pubmed, xml_tree) xml_pubmed_article_id(xml_pubmed, xml_tree) xml_pubmed_history(xml_pubmed, xml_tree) @@ -353,7 +354,14 @@ def add_orcid(author_reg, author_tag): def xml_pubmed_author_list(xml_pubmed, xml_tree): - authors = list(get_authors(xml_tree)) + # Contribs de contrib-group[@content-type="collab-list"] são os membros + # nomeados de um grupo de autoria (ver xml_pubmed_group_list) e não + # devem aparecer soltos em AuthorList. + authors = [ + author_reg + for author_reg in get_authors(xml_tree) + if author_reg.get("contrib-group-type") != "collab-list" + ] if authors: author_list_tag = ET.Element("AuthorList") for author_reg in authors: @@ -381,30 +389,77 @@ def xml_pubmed_author_list(xml_pubmed, xml_tree): add_orcid(author_reg, author_tag) author_list_tag.append(author_tag) - # TODO - # add_group_list(author_reg, author_tag) - # Group information should be enclosed in these tags. If an article has one or more Groups, this tag - # must be submitted. Groups should be listed in the same order as in the printed article, and Group name - # format should accurately reflect the article. This tag is Required if the tag Group is present. - # There is no example of using this value in the files + xml_pubmed.append(author_list_tag) - # TODO - # add_group(author_reg, author_tag) - # Information about a single Group must begin with this tag. - # There is no example of using this value in the files - # TODO - # add_group_name(author_reg, author_tag) - # The name of the authoring committee or organization. Omit extraneous text like, “on behalf of.” - # There is no example of using this value in the files +def get_group_members(authors): + return [ + author_reg + for author_reg in authors + if author_reg.get("contrib-group-type") == "collab-list" + ] - # TODO - # add_individual_name(author_reg, author_tag) - # The name of individual members belonging to the authoring committee or organization. - # The name should be tagged with the FirstName, MiddleName, LastName, Suffix, and Affiliation tags. - # There is no example of using this value in the files - xml_pubmed.append(author_list_tag) +def get_group_name(authors): + for author_reg in authors: + if author_reg.get("collab") and author_reg.get("contrib-group-type") != "collab-list": + return author_reg.get("collab") + + +def add_individual_name(member_reg, xml_tree, group_tag): + """ + + Felipe + Esteves + Universidade Federal do Pará (UFPA) + + """ + individual_name_tag = ET.Element("IndividualName") + add_first_name(member_reg, individual_name_tag) + add_last_name(member_reg, individual_name_tag) + add_suffix(member_reg, individual_name_tag) + affiliations = get_affiliations(member_reg, xml_tree) + add_affiliations(affiliations, individual_name_tag) + add_orcid(member_reg, individual_name_tag) + group_tag.append(individual_name_tag) + + +def xml_pubmed_group_list(xml_pubmed, xml_tree): + """ + + + The SciELO Group + + Felipe + Esteves + Universidade Federal do Pará (UFPA) + + + + + Membros de contrib-group[@content-type="collab-list"] (ver + xml_pubmed_author_list), agrupados sob o nome do grupo declarado no + do contrib-group irmão. + """ + authors = list(get_authors(xml_tree)) + members = get_group_members(authors) + if not members: + return + + group_list_tag = ET.Element("GroupList") + group_tag = ET.Element("Group") + + group_name = get_group_name(authors) + if group_name: + group_name_tag = ET.Element("GroupName") + group_name_tag.text = group_name + group_tag.append(group_name_tag) + + for member_reg in members: + add_individual_name(member_reg, xml_tree, group_tag) + + group_list_tag.append(group_tag) + xml_pubmed.append(group_list_tag) def get_publication_type(xml_tree): diff --git a/tests/sps/formats/test_pubmed.py b/tests/sps/formats/test_pubmed.py index 1fdfb2dda..ba2606a14 100644 --- a/tests/sps/formats/test_pubmed.py +++ b/tests/sps/formats/test_pubmed.py @@ -16,6 +16,7 @@ xml_pubmed_elocation_pipe, xml_pubmed_language_pipe, xml_pubmed_author_list, + xml_pubmed_group_list, xml_pubmed_publication_type, xml_pubmed_article_id, xml_pubmed_history, @@ -1094,6 +1095,149 @@ def test_xml_pubmed_author_list_with_collective_name(self): self.assertEqual(obtained, expected) + def test_xml_pubmed_author_list_excludes_collab_list_members(self): + expected = ( + '
' + '' + '' + 'The SciELO Group' + '' + '' + '
' + ) + xml_pubmed = ET.fromstring( + '
' + ) + xml_tree = ET.fromstring( + '
' + '' + '' + '' + '' + 'The SciELO Group' + '1' + '' + '' + '' + '' + '' + 'Esteves' + 'Felipe' + '' + '' + '' + '' + '' + '
' + ) + + xml_pubmed_author_list(xml_pubmed, xml_tree) + + obtained = ET.tostring(xml_pubmed, encoding="utf-8").decode("utf-8") + + self.assertEqual(obtained, expected) + + def test_xml_pubmed_group_list(self): + expected = ( + '
' + '' + '' + 'The SciELO Group' + '' + 'Felipe' + 'Esteves' + 'Universidade Federal do Pará (UFPA)' + 'http://orcid.org/0000-0001-0002-0003' + '' + '' + 'Joyce' + 'Souza' + 'Universidade Federal do Pará (UFPA)' + '' + '' + '' + '
' + ) + xml_pubmed = ET.fromstring( + '
' + ) + xml_tree = ET.fromstring( + '
' + '' + '' + '' + '' + 'The SciELO Group' + '1' + '' + '' + '' + '' + '0000-0001-0002-0003' + '' + 'Esteves' + 'Felipe' + '' + '1' + '' + '' + '' + 'Souza' + 'Joyce' + '' + '1' + '' + '' + '' + 'Universidade Federal do Pará (UFPA)' + '' + '' + '' + '
' + ) + + xml_pubmed_group_list(xml_pubmed, xml_tree) + + obtained = ET.tostring(xml_pubmed, encoding="utf-8").decode("utf-8") + + self.assertEqual(obtained, expected) + + def test_xml_pubmed_group_list_without_group(self): + expected = ( + '
' + ) + xml_pubmed = ET.fromstring( + '
' + ) + xml_tree = ET.fromstring( + '
' + '' + '' + '' + '' + '' + 'Meneghini' + 'Rogerio' + '' + '' + '' + '' + '' + '
' + ) + + xml_pubmed_group_list(xml_pubmed, xml_tree) + + obtained = ET.tostring(xml_pubmed, encoding="utf-8").decode("utf-8") + + self.assertEqual(obtained, expected) + def test_xml_pubmed_publication_type(self): # TODO # Originalmente, espera-se que o valor da tag seja Journal Article From 755830d8aacc6d2017c09f4fb6a0453d2ed8db02 Mon Sep 17 00:00:00 2001 From: Rossi-Luciano Date: Fri, 3 Jul 2026 23:02:01 -0300 Subject: [PATCH 3/3] =?UTF-8?q?fix(pubmed):=20duplicar=20nome=20quando=20s?= =?UTF-8?q?urname=20ou=20given-names=20est=C3=A1=20ausente?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A DTD do PubMed exige FirstName e LastName sempre juntos em Author (nenhum dos dois é opcional isoladamente): Author (((FirstName, MiddleName?, LastName, Suffix?, Initials?) | CollectiveName), ...) Encontrado ao validar a saída da CLI em lote (#1241) contra a DTD real usando tests/samples/example.xml: um contrib tem mas não tem , gerando ...... sem — inválido segundo a DTD (confirmado empiricamente: Author só com FirstName ou só com LastName sempre falha a validação). add_first_name e add_last_name agora caem para o outro campo quando o esperado está ausente, duplicando o único nome disponível em ambos — preserva a identificação do autor em vez de descartá-lo ou gerar XML inválido. Refs #1226, #1236 --- packtools/sps/formats/pubmed.py | 10 ++++++-- tests/sps/formats/test_pubmed.py | 41 ++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/packtools/sps/formats/pubmed.py b/packtools/sps/formats/pubmed.py index 634fd94f1..89f26ddb3 100644 --- a/packtools/sps/formats/pubmed.py +++ b/packtools/sps/formats/pubmed.py @@ -290,7 +290,12 @@ def get_authors(xml_tree): def add_first_name(author_reg, author_tag): - author_first_name = author_reg.get("contrib_name", {}).get("given-names") + contrib_name = author_reg.get("contrib_name", {}) + # A DTD do PubMed exige FirstName e LastName juntos (nenhum dos dois é + # opcional isoladamente). Quando o XML fonte só tem um dos dois campos + # (autores sem , por exemplo), duplicamos o valor disponível + # em ambos para gerar um Author válido em vez de descartar o autor. + author_first_name = contrib_name.get("given-names") or contrib_name.get("surname") if author_first_name: first = ET.Element("FirstName") first.text = author_first_name @@ -298,7 +303,8 @@ def add_first_name(author_reg, author_tag): def add_last_name(author_reg, author_tag): - author_last_name = author_reg.get("contrib_name", {}).get("surname") + contrib_name = author_reg.get("contrib_name", {}) + author_last_name = contrib_name.get("surname") or contrib_name.get("given-names") if author_last_name: last = ET.Element("LastName") last.text = author_last_name diff --git a/tests/sps/formats/test_pubmed.py b/tests/sps/formats/test_pubmed.py index 1fdfb2dda..98d70328a 100644 --- a/tests/sps/formats/test_pubmed.py +++ b/tests/sps/formats/test_pubmed.py @@ -1058,6 +1058,47 @@ def test_xml_pubmed_author_list_with_suffix(self): self.assertEqual(obtained, expected) + def test_xml_pubmed_author_list_without_surname_duplicates_given_names(self): + # A DTD do PubMed exige FirstName e LastName juntos; quando o XML + # fonte só tem (sem ), duplicamos o valor + # em ambos os campos em vez de gerar um Author inválido. + expected = ( + '
' + '' + '' + 'Viviana Alder' + 'Viviana Alder' + '' + '' + '
' + ) + xml_pubmed = ET.fromstring( + '
' + ) + xml_tree = ET.fromstring( + '
' + '' + '' + '' + '' + '' + 'Viviana Alder' + '' + '' + '' + '' + '' + '
' + ) + + xml_pubmed_author_list(xml_pubmed, xml_tree) + + obtained = ET.tostring(xml_pubmed, encoding="utf-8").decode("utf-8") + + self.assertEqual(obtained, expected) + def test_xml_pubmed_author_list_with_collective_name(self): expected = ( '
'